feat: ad interface to upload logo and favicon

This commit is contained in:
2026-01-23 11:43:17 -03:00
parent 3ae67b23c9
commit 60e2af72f0
9 changed files with 346 additions and 38 deletions
+1
View File
@@ -409,6 +409,7 @@ Obtiene una lista de todas las organizaciones registradas.
- **Global Localization**: Native support for English, Spanish, and Portuguese. - **Global Localization**: Native support for English, Spanish, and Portuguese.
- **PDF Integrated Viewer**: Read academic documents without leaving the platform. - **PDF Integrated Viewer**: Read academic documents without leaving the platform.
- **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons. - **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons.
- **White-Label Branding**: Fully custom platform name, logo, favicon, and color themes per organization.
## 📄 Licencia ## 📄 Licencia
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
+109
View File
@@ -1773,6 +1773,23 @@ pub struct AuthPayload {
pub organization_name: Option<String>, pub organization_name: Option<String>,
} }
#[derive(serde::Deserialize)]
pub struct ProvisionPayload {
pub org_name: String,
pub org_domain: Option<String>,
pub admin_email: String,
pub admin_password: String,
pub admin_full_name: String,
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct AdminCreateUserPayload {
pub email: String,
pub password: String,
pub full_name: String,
pub role: String,
}
pub async fn register( pub async fn register(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(payload): Json<AuthPayload>, Json(payload): Json<AuthPayload>,
@@ -1842,6 +1859,52 @@ pub async fn register(
})) }))
} }
pub async fn admin_create_user(
Org(org_ctx): Org,
claims: common::auth::Claims,
State(pool): State<PgPool>,
Json(payload): Json<AdminCreateUserPayload>,
) -> Result<Json<UserResponse>, (StatusCode, String)> {
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
}
if !["admin", "instructor", "student"].contains(&payload.role.as_str()) {
return Err((StatusCode::BAD_REQUEST, "Invalid role".into()));
}
let password_hash = hash(payload.password, DEFAULT_COST)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
let user = sqlx::query_as::<_, User>(
"INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5) RETURNING *"
)
.bind(&payload.email)
.bind(password_hash)
.bind(&payload.full_name)
.bind(&payload.role)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Failed to create user: {}", e);
(StatusCode::CONFLICT, "User already exists or DB error".into())
})?;
Ok(Json(UserResponse {
id: user.id,
email: user.email,
full_name: user.full_name,
role: user.role,
organization_id: user.organization_id,
xp: user.xp,
level: user.level,
avatar_url: user.avatar_url,
bio: user.bio,
language: user.language,
}))
}
pub async fn login( pub async fn login(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(payload): Json<AuthPayload>, Json(payload): Json<AuthPayload>,
@@ -2758,6 +2821,52 @@ pub async fn create_organization(
Ok(Json(org)) Ok(Json(org))
} }
pub async fn provision_organization(
claims: common::auth::Claims,
State(pool): State<PgPool>,
Json(payload): Json<ProvisionPayload>,
) -> Result<Json<Organization>, (StatusCode, String)> {
if claims.role != "admin" || claims.org != Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() {
return Err((StatusCode::FORBIDDEN, "Super Admin access required".into()));
}
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let org = sqlx::query_as::<_, Organization>(
"INSERT INTO organizations (name, domain) VALUES ($1, $2) RETURNING *"
)
.bind(&payload.org_name)
.bind(&payload.org_domain)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Failed to create organization: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create organization".into())
})?;
let password_hash = hash(payload.admin_password, DEFAULT_COST)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
sqlx::query(
"INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5)"
)
.bind(&payload.admin_email)
.bind(password_hash)
.bind(&payload.admin_full_name)
.bind("admin")
.bind(org.id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Failed to create admin user: {}", e);
(StatusCode::CONFLICT, "User already exists or DB error".into())
})?;
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(org))
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct CreateWebhookPayload { pub struct CreateWebhookPayload {
pub url: String, pub url: String,
+2 -1
View File
@@ -139,7 +139,7 @@ async fn main() {
get(handlers::get_grading_categories), get(handlers::get_grading_categories),
) )
.route("/auth/me", get(handlers::get_me)) .route("/auth/me", get(handlers::get_me))
.route("/users", get(handlers::get_all_users)) .route("/users", get(handlers::get_all_users).post(handlers::admin_create_user))
.route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/users/{id}", axum::routing::put(handlers::update_user))
.route("/audit-logs", get(handlers::get_audit_logs)) .route("/audit-logs", get(handlers::get_audit_logs))
.route("/api/ai/review-text", post(handlers::review_text)) .route("/api/ai/review-text", post(handlers::review_text))
@@ -151,6 +151,7 @@ async fn main() {
"/organizations", "/organizations",
get(handlers::get_organizations).post(handlers::create_organization), get(handlers::get_organizations).post(handlers::create_organization),
) )
.route("/admin/provision", post(handlers::provision_organization))
.route( .route(
"/webhooks", "/webhooks",
get(handlers::get_webhooks).post(handlers::create_webhook), get(handlers::get_webhooks).post(handlers::create_webhook),
@@ -13,6 +13,11 @@ export default function OrganizationsPage() {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newDomain, setNewDomain] = useState(''); const [newDomain, setNewDomain] = useState('');
// Admin User States
const [adminFullName, setAdminFullName] = useState('');
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
// Branding States // Branding States
const [isBrandingModalOpen, setIsBrandingModalOpen] = useState(false); const [isBrandingModalOpen, setIsBrandingModalOpen] = useState(false);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null); const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
@@ -49,13 +54,23 @@ export default function OrganizationsPage() {
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
await cmsApi.createOrganization(newName, newDomain || undefined); await cmsApi.provisionOrganization({
org_name: newName,
org_domain: newDomain || undefined,
admin_full_name: adminFullName,
admin_email: adminEmail,
admin_password: adminPassword
});
setNewName(''); setNewName('');
setNewDomain(''); setNewDomain('');
setAdminFullName('');
setAdminEmail('');
setAdminPassword('');
setIsModalOpen(false); setIsModalOpen(false);
loadOrganizations(); loadOrganizations();
} catch (error) { } catch (error) {
console.error('Failed to create organization', error); console.error('Failed to create organization', error);
alert('Failed to provision organization. Please ensure the email is unique.');
} }
}; };
@@ -271,10 +286,49 @@ export default function OrganizationsPage() {
type="text" type="text"
value={newDomain} value={newDomain}
onChange={(e) => setNewDomain(e.target.value)} onChange={(e) => setNewDomain(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all" className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm"
placeholder="e.g. acme.com" placeholder="e.g. acme.com"
/> />
</div> </div>
<div className="pt-4 border-t border-white/5">
<h3 className="text-xs font-black uppercase tracking-widest text-blue-500 mb-4">Initial Administrator</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-1.5">Admin Full Name</label>
<input
type="text"
required
value={adminFullName}
onChange={(e) => setAdminFullName(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
placeholder="e.g. John Doe"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1.5">Admin Email</label>
<input
type="email"
required
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
placeholder="admin@acme.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1.5">Admin Password</label>
<input
type="password"
required
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
placeholder="••••••••"
/>
</div>
</div>
</div>
<div className="flex gap-3 mt-8"> <div className="flex gap-3 mt-8">
<button <button
type="button" type="button"
+130 -4
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { cmsApi, User, Organization } from '@/lib/api'; import { cmsApi, User, Organization } from '@/lib/api';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { UserCog, Mail, Search, Filter, ShieldCheck } from 'lucide-react'; import { UserCog, Mail, Search, Filter, ShieldCheck, Plus, X, UserPlus, Key, User as UserIcon } from 'lucide-react';
export default function UsersPage() { export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
@@ -13,6 +13,15 @@ export default function UsersPage() {
const [roleFilter, setRoleFilter] = useState(''); const [roleFilter, setRoleFilter] = useState('');
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
// Create User States
const [isModalOpen, setIsModalOpen] = useState(false);
const [newUser, setNewUser] = useState({
email: '',
password: '',
full_name: '',
role: 'student'
});
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []); }, []);
@@ -41,6 +50,19 @@ export default function UsersPage() {
} }
}; };
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
try {
await cmsApi.createUser(newUser);
setNewUser({ email: '', password: '', full_name: '', role: 'student' });
setIsModalOpen(false);
loadData();
} catch (error) {
console.error('Failed to create user', error);
alert('Failed to create user. Ensure email is unique.');
}
};
const filteredUsers = users.filter(u => { const filteredUsers = users.filter(u => {
const matchesSearch = u.full_name.toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch = u.full_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase()); u.email.toLowerCase().includes(searchTerm.toLowerCase());
@@ -62,9 +84,18 @@ export default function UsersPage() {
return ( return (
<div className="space-y-8 animate-in fade-in duration-500"> <div className="space-y-8 animate-in fade-in duration-500">
<div> <div className="flex justify-between items-center">
<h1 className="text-3xl font-bold tracking-tight">User Management</h1> <div>
<p className="text-gray-400 mt-1">Manage global users, roles, and organization assignments.</p> <h1 className="text-3xl font-bold tracking-tight">User Management</h1>
<p className="text-gray-400 mt-1">Manage users, roles, and organization assignments.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20"
>
<Plus className="w-4 h-4" />
Add User
</button>
</div> </div>
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col md:flex-row gap-4">
@@ -167,6 +198,101 @@ export default function UsersPage() {
</div> </div>
)} )}
</div> </div>
{/* Create User Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-md glass border border-white/10 rounded-2xl p-8 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-400">
<UserPlus className="w-5 h-5" />
</div>
<h2 className="text-xl font-bold">Add New User</h2>
</div>
<button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-white/5 rounded-full transition-colors text-gray-500">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleCreateUser} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-1.5">Full Name</label>
<div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
required
value={newUser.full_name}
onChange={(e) => setNewUser({ ...newUser, full_name: e.target.value })}
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
placeholder="e.g. John Doe"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1.5">Email Address</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="email"
required
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm"
placeholder="user@example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1.5">Initial Password</label>
<div className="relative">
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="password"
required
value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
placeholder="••••••••"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1.5">User Role</label>
<select
value={newUser.role}
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all appearance-none cursor-pointer"
>
<option value="student">Student</option>
<option value="instructor">Instructor</option>
<option value="admin">Administrator (Organization Admin)</option>
</select>
</div>
<div className="flex gap-3 mt-8">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
>
Cancel
</button>
<button
type="submit"
className="flex-[2] px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 font-bold"
>
Create User
</button>
</div>
</form>
</div>
</div>
)}
</div> </div>
); );
} }
+5 -2
View File
@@ -16,6 +16,7 @@ import {
LogOut, LogOut,
Trash2 Trash2
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
export default function ProfilePage() { export default function ProfilePage() {
const { t, setLanguage: setContextLanguage } = useTranslation(); const { t, setLanguage: setContextLanguage } = useTranslation();
@@ -102,10 +103,12 @@ export default function ProfilePage() {
<div className="relative mb-6"> <div className="relative mb-6">
<div className="w-32 h-32 rounded-full bg-blue-600/20 border-4 border-white/5 flex items-center justify-center overflow-hidden shadow-2xl relative"> <div className="w-32 h-32 rounded-full bg-blue-600/20 border-4 border-white/5 flex items-center justify-center overflow-hidden shadow-2xl relative">
{avatarUrl ? ( {avatarUrl ? (
<img <Image
src={getImageUrl(avatarUrl)} src={getImageUrl(avatarUrl)}
alt={fullName} alt={fullName}
className="w-full h-full object-cover" fill
className="object-cover"
sizes="128px"
/> />
) : ( ) : (
<span className="text-5xl font-black text-blue-400"> <span className="text-5xl font-black text-blue-400">
+4 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { LogOut, ShieldAlert, Building2, Activity } from "lucide-react"; import { LogOut, ShieldAlert, Building2, Activity, Settings } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
export default function AuthHeader() { export default function AuthHeader() {
@@ -19,6 +19,9 @@ export default function AuthHeader() {
<Link href="/admin/tasks" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2"> <Link href="/admin/tasks" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<Activity size={16} /> Tasks <Activity size={16} /> Tasks
</Link> </Link>
<Link href="/settings" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<Settings size={16} /> Settings
</Link>
</> </>
)} )}
{user && ( {user && (
+22 -28
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Image from "next/image";
import { cmsApi, Organization, BrandingPayload, getImageUrl } from "@/lib/api"; import { cmsApi, Organization, BrandingPayload, getImageUrl } from "@/lib/api";
import FileUpload from "./FileUpload"; import FileUpload from "./FileUpload";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -41,10 +42,9 @@ export default function BrandingSettings() {
setSaving(true); setSaving(true);
try { try {
await cmsApi.updateOrganizationBranding(org.id, formData); await cmsApi.updateOrganizationBranding(org.id, formData);
// Refresh to update local state logic if needed
fetchOrg(); fetchOrg();
alert("Branding updated successfully!"); alert("Branding updated successfully!");
router.refresh(); // Refresh layouts to pick up new branding router.refresh();
} catch (error) { } catch (error) {
console.error("Failed to update branding:", error); console.error("Failed to update branding:", error);
alert("Failed to update branding settings."); alert("Failed to update branding settings.");
@@ -53,25 +53,6 @@ export default function BrandingSettings() {
} }
}; };
const handleLogoUpload = async (file: File, onProgress: (pct: number) => void) => {
if (!org) throw new Error("No organization loaded");
// Simulate progress for smoother UX if the API doesn't support it natively for direct fetch
// API wrapper uses fetch/xhr so branding API in api.ts needs to handle it.
// For now, we assume simple upload.
onProgress(50);
const res = await cmsApi.uploadOrganizationLogo(org.id, file);
onProgress(100);
return { url: res.logo_url }; // api returns { logo_url: ... }
};
const handleFaviconUpload = async (file: File, onProgress: (pct: number) => void) => {
if (!org) throw new Error("No organization loaded");
onProgress(50);
const res = await cmsApi.uploadOrganizationFavicon(org.id, file);
onProgress(100);
return { url: res.favicon_url };
};
if (loading) return <div className="p-8 text-center text-gray-400 animate-pulse">Loading settings...</div>; if (loading) return <div className="p-8 text-center text-gray-400 animate-pulse">Loading settings...</div>;
if (!org) return <div className="p-8 text-center text-red-400">Failed to load organization settings.</div>; if (!org) return <div className="p-8 text-center text-red-400">Failed to load organization settings.</div>;
@@ -99,9 +80,15 @@ export default function BrandingSettings() {
{/* Logo Section */} {/* Logo Section */}
<div className="space-y-4"> <div className="space-y-4">
<label className="block text-sm font-medium text-gray-400">Logo</label> <label className="block text-sm font-medium text-gray-400">Logo</label>
<div className="p-4 bg-black/20 rounded-xl border border-white/5 flex items-center justify-center min-h-[120px]"> <div className="p-4 bg-black/20 rounded-xl border border-white/5 flex items-center justify-center min-h-[120px] relative">
{org.logo_url ? ( {org.logo_url ? (
<img src={getImageUrl(org.logo_url)} alt="Logo" className="max-h-16 object-contain" /> <Image
src={getImageUrl(org.logo_url)}
alt="Logo"
fill
className="object-contain p-2"
sizes="100px"
/>
) : ( ) : (
<span className="text-gray-600 text-sm">No logo uploaded</span> <span className="text-gray-600 text-sm">No logo uploaded</span>
)} )}
@@ -109,8 +96,7 @@ export default function BrandingSettings() {
<FileUpload <FileUpload
accept="image/png,image/jpeg,image/svg+xml" accept="image/png,image/jpeg,image/svg+xml"
currentUrl={org.logo_url} currentUrl={org.logo_url}
customUploadFn={async (file, onProgress) => { customUploadFn={async (file) => {
// Adapt the response format from logo upload API to what FileUpload expects
const res = await cmsApi.uploadOrganizationLogo(org.id, file); const res = await cmsApi.uploadOrganizationLogo(org.id, file);
return { url: res.logo_url || "" }; return { url: res.logo_url || "" };
}} }}
@@ -125,9 +111,17 @@ export default function BrandingSettings() {
{/* Favicon Section */} {/* Favicon Section */}
<div className="space-y-4"> <div className="space-y-4">
<label className="block text-sm font-medium text-gray-400">Favicon</label> <label className="block text-sm font-medium text-gray-400">Favicon</label>
<div className="p-4 bg-black/20 rounded-xl border border-white/5 flex items-center justify-center min-h-[120px]"> <div className="p-4 bg-black/20 rounded-xl border border-white/5 flex items-center justify-center min-h-[120px] relative">
{org.favicon_url ? ( {org.favicon_url ? (
<img src={getImageUrl(org.favicon_url)} alt="Favicon" className="w-8 h-8 object-contain" /> <div className="w-8 h-8 relative">
<Image
src={getImageUrl(org.favicon_url)}
alt="Favicon"
fill
className="object-contain"
sizes="32px"
/>
</div>
) : ( ) : (
<span className="text-gray-600 text-sm">No favicon</span> <span className="text-gray-600 text-sm">No favicon</span>
)} )}
@@ -135,7 +129,7 @@ export default function BrandingSettings() {
<FileUpload <FileUpload
accept="image/png,image/x-icon,image/svg+xml,image/jpeg" accept="image/png,image/x-icon,image/svg+xml,image/jpeg"
currentUrl={org.favicon_url} currentUrl={org.favicon_url}
customUploadFn={async (file, onProgress) => { customUploadFn={async (file) => {
const res = await cmsApi.uploadOrganizationFavicon(org.id, file); const res = await cmsApi.uploadOrganizationFavicon(org.id, file);
return { url: res.favicon_url || "" }; return { url: res.favicon_url || "" };
}} }}
+17
View File
@@ -142,6 +142,21 @@ export interface OrganizationSSOConfig {
updated_at: string; updated_at: string;
} }
export interface ProvisionPayload {
org_name: string;
org_domain?: string;
admin_email: string;
admin_password: string;
admin_full_name: string;
}
export interface UserCreatePayload {
email: string;
password: string;
full_name: string;
role: string;
}
export interface AuthResponse { export interface AuthResponse {
user: User; user: User;
token: string; token: string;
@@ -272,6 +287,7 @@ export const cmsApi = {
getOrganization: (): Promise<Organization> => apiFetch('/organization'), getOrganization: (): Promise<Organization> => apiFetch('/organization'),
getOrganizations: (): Promise<Organization[]> => apiFetch('/organizations'), getOrganizations: (): Promise<Organization[]> => apiFetch('/organizations'),
createOrganization: (name: string, domain?: string): Promise<Organization> => apiFetch('/organizations', { method: 'POST', body: JSON.stringify({ name, domain }) }), createOrganization: (name: string, domain?: string): Promise<Organization> => apiFetch('/organizations', { method: 'POST', body: JSON.stringify({ name, domain }) }),
provisionOrganization: (data: ProvisionPayload): Promise<Organization> => apiFetch('/admin/provision', { method: 'POST', body: JSON.stringify(data) }),
// Auth // Auth
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }), register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
@@ -327,6 +343,7 @@ export const cmsApi = {
// Users // Users
getAllUsers: (): Promise<User[]> => apiFetch('/users'), getAllUsers: (): Promise<User[]> => apiFetch('/users'),
createUser: (data: UserCreatePayload): Promise<User> => apiFetch('/users', { method: 'POST', body: JSON.stringify(data) }),
updateUser: (id: string, payload: { role?: string, organization_id?: string, full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), updateUser: (id: string, payload: { role?: string, organization_id?: string, full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
// Webhooks // Webhooks