feat: ad interface to upload logo and favicon
This commit is contained in:
@@ -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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 || "" };
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user