diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 8724e26..979ca07 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -6,7 +6,19 @@ export default function AuthLayout({ children: React.ReactNode; }>) { return ( -
+
+
+
+
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 2f3aefb..0b9c985 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,20 +1,46 @@ "use client"; -import { useState, Suspense } from "react"; +import { useState, Suspense, useEffect, useCallback } from "react"; +import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; -import { KeyRound, Loader2, ShieldCheck, ArrowLeft, Eye, EyeOff } from "lucide-react"; +import { + KeyRound, + Loader2, + Mail, + ShieldCheck, + ArrowLeft, + Eye, + EyeOff, + Sparkles, + CheckCircle2, + Smartphone, +} from "lucide-react"; import { startAuthentication } from "@simplewebauthn/browser"; +import Link from "next/link"; +import QRCode from "qrcode"; import { apiClient } from "@/lib/api-client"; import { useAuthStore } from "@/stores/auth-store"; -import { AuthResponse, MFAChallengeResponse } from "@/lib/types"; +import { + AuthResponse, + MFAChallengeResponse, + MFAEnrollResponse, + MFAEnrollmentRequiredResponse, + PublicTenantAuthConfig, +} from "@/lib/types"; import { getApiErrorMessage, isNotAllowedError } from "@/lib/errors"; -import { FaGithub, FaGoogle, FaMicrosoft } from "react-icons/fa"; +import { + PLATFORM_TENANT_ID, + PublicOAuthProvider, + SOCIAL_PROVIDER_META, +} from "@/lib/social-login"; +import { isWebAuthnSupported } from "@/lib/webauthn-support"; +import { LoginFormSkeleton } from "@/components/auth/login-form-skeleton"; import { Button } from "@/components/ui/button"; import { @@ -26,21 +52,147 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import Link from "next/link"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +const LOGIN_TAB_KEY = "authengine_login_tab"; +const MAGIC_LINK_COOLDOWN_SEC = 60; const loginSchema = z.object({ email: z.string().email({ message: "Please enter a valid email address." }), password: z.string().min(1, { message: "Password is required." }), }); +const passwordOnlySchema = z.object({ + password: z.string().min(1, { message: "Password is required." }), +}); + +const magicLinkSchema = z.object({ + email: z.string().email({ message: "Please enter a valid email address." }), +}); + +function validateEmail(email: string): string | null { + const result = z.string().email().safeParse(email.trim()); + return result.success ? null : "Please enter a valid email address."; +} + +function AuthShell({ + title, + description, + children, + footer, +}: { + title: string; + description: string; + children: React.ReactNode; + footer?: React.ReactNode; +}) { + return ( +
+
+ + +
+ AuthEngine +
+

{title}

+

{description}

+
+
+ {children} +
+ {footer ? ( + + {footer} + + ) : null} +
+
+
+ ); +} + +function SectionDivider({ label }: { label: string }) { + return ( +
+
+ +
+
+ + {label} + +
+
+ ); +} + +function InlineError({ message }: { message: string }) { + return ( +

+ {message} +

+ ); +} + +function MagicLinkSentPanel({ + email, + resendCooldown, + isResending, + onResend, + onChangeEmail, +}: { + email: string; + resendCooldown: number; + isResending: boolean; + onResend: () => void; + onChangeEmail: () => void; +}) { + return ( +
+
+ +
+

Check your inbox

+

+ We sent a sign-in link to{" "} + {email}. It expires in + 15 minutes and works once. +

+
+
+
+ + +
+
+ ); +} + function LoginPageContent() { const router = useRouter(); const searchParams = useSearchParams(); @@ -49,14 +201,96 @@ function LoginPageContent() { const [mfaPendingToken, setMfaPendingToken] = useState(null); const [mfaCode, setMfaCode] = useState(""); + const [mfaError, setMfaError] = useState(null); + const [mfaEnrollmentToken, setMfaEnrollmentToken] = useState(null); + const [enrollData, setEnrollData] = useState(null); + const [enrollmentQrCode, setEnrollmentQrCode] = useState(""); + const [enrollmentCode, setEnrollmentCode] = useState(""); + const [enrollmentError, setEnrollmentError] = useState(null); const [isWebAuthnLoading, setIsWebAuthnLoading] = useState(false); + const [passkeyError, setPasskeyError] = useState(null); const [showPassword, setShowPassword] = useState(false); + const [credentialTab, setCredentialTab] = useState<"password" | "magic_link">("password"); + const [sharedEmail, setSharedEmail] = useState(""); + const [sharedEmailError, setSharedEmailError] = useState(null); + const [magicLinkSentEmail, setMagicLinkSentEmail] = useState(null); + const [resendCooldown, setResendCooldown] = useState(0); + const [webAuthnSupported, setWebAuthnSupported] = useState(false); + + useEffect(() => { + setWebAuthnSupported(isWebAuthnSupported()); + const saved = localStorage.getItem(LOGIN_TAB_KEY); + if (saved === "password" || saved === "magic_link") { + setCredentialTab(saved); + } + }, []); - const form = useForm>({ + useEffect(() => { + if (resendCooldown <= 0) return; + const timer = window.setTimeout(() => setResendCooldown((c) => c - 1), 1000); + return () => window.clearTimeout(timer); + }, [resendCooldown]); + + useEffect(() => { + if (!enrollData?.provisioning_uri) { + setEnrollmentQrCode(""); + return; + } + + QRCode.toDataURL(enrollData.provisioning_uri) + .then(setEnrollmentQrCode) + .catch(() => setEnrollmentQrCode("")); + }, [enrollData]); + + const { data: authConfig, isLoading: isLoadingAuthConfig } = useQuery({ + queryKey: ["publicAuthConfig", PLATFORM_TENANT_ID], + queryFn: async () => { + const { data } = await apiClient.get("/auth/auth-config", { + params: { tenant_id: PLATFORM_TENANT_ID }, + }); + return data; + }, + enabled: !!PLATFORM_TENANT_ID, + }); + + const allowedMethods = new Set(authConfig?.allowed_methods ?? []); + const showEmailPassword = allowedMethods.has("email_password"); + const showMagicLink = allowedMethods.has("magic_link"); + const showPasskeyConfig = allowedMethods.has("passkey"); + const showSocial = allowedMethods.has("social_provider"); + const showCredentialTabs = showEmailPassword && showMagicLink; + const showEmailField = showEmailPassword || showMagicLink; + const showPasskeySection = showPasskeyConfig && webAuthnSupported; + + const { data: socialProviders = [], isLoading: isLoadingSocialProviders } = useQuery< + PublicOAuthProvider[] + >({ + queryKey: ["loginSocialProviders", PLATFORM_TENANT_ID], + queryFn: async () => { + const { data } = await apiClient.get( + "/auth/oauth/providers", + { params: { tenant_id: PLATFORM_TENANT_ID } } + ); + return data; + }, + enabled: !!PLATFORM_TENANT_ID && showSocial, + }); + + const passwordForm = useForm>({ resolver: zodResolver(loginSchema), defaultValues: { email: "", password: "" }, }); + const passwordOnlyForm = useForm>({ + resolver: zodResolver(passwordOnlySchema), + defaultValues: { password: "" }, + }); + + const magicForm = useForm>({ + resolver: zodResolver(magicLinkSchema), + defaultValues: { email: "" }, + }); + const handleLoginSuccess = (data: AuthResponse) => { setTokens(data.access_token, data.refresh_token); if (data.user) setUser(data.user); @@ -64,21 +298,69 @@ function LoginPageContent() { router.push(returnTo); }; + const handleCredentialTabChange = (value: string) => { + const tab = value as "password" | "magic_link"; + setCredentialTab(tab); + localStorage.setItem(LOGIN_TAB_KEY, tab); + setSharedEmailError(null); + passwordForm.clearErrors("root"); + magicForm.clearErrors("root"); + }; + + const getEmailForSubmit = useCallback( + (formEmail?: string) => (showCredentialTabs ? sharedEmail.trim() : (formEmail ?? "").trim()), + [showCredentialTabs, sharedEmail] + ); + + const sendMagicLink = async (email: string) => { + await apiClient.post("/auth/magic-link/request", { + email, + tenant_id: PLATFORM_TENANT_ID || undefined, + }); + }; + const loginMutation = useMutation({ - mutationFn: async (values: z.infer) => { - const { data } = await apiClient.post("/auth/login", values); + mutationFn: async (values: { email: string; password: string }) => { + const { data } = await apiClient.post("/auth/login", { + ...values, + tenant_id: PLATFORM_TENANT_ID || undefined, + }); return data; }, - onSuccess: (data: AuthResponse | MFAChallengeResponse) => { + onSuccess: (data: AuthResponse | MFAChallengeResponse | MFAEnrollmentRequiredResponse) => { + passwordForm.clearErrors("root"); if ("mfa_pending_token" in data && data.mfa_pending_token) { setMfaPendingToken(data.mfa_pending_token); - toast.info("Enter your MFA code to continue."); + setMfaError(null); + return; + } + if ("mfa_enrollment_token" in data && data.mfa_enrollment_token) { + setMfaEnrollmentToken(data.mfa_enrollment_token); + setEnrollData(null); + setEnrollmentCode(""); + setEnrollmentError(null); return; } handleLoginSuccess(data as AuthResponse); }, onError: (error: unknown) => { - toast.error(getApiErrorMessage(error, "Invalid email or password.")); + passwordForm.setError("root", { + message: getApiErrorMessage(error, "Invalid email or password."), + }); + }, + }); + + const magicLinkMutation = useMutation({ + mutationFn: sendMagicLink, + onSuccess: (_data, email) => { + magicForm.clearErrors("root"); + setMagicLinkSentEmail(email); + setResendCooldown(MAGIC_LINK_COOLDOWN_SEC); + }, + onError: (error: unknown) => { + magicForm.setError("root", { + message: getApiErrorMessage(error, "Failed to send magic link."), + }); }, }); @@ -91,266 +373,670 @@ function LoginPageContent() { return data; }, onSuccess: handleLoginSuccess, - onError: () => toast.error("Invalid MFA code."), + onError: () => setMfaError("Invalid MFA code. Please try again."), }); + const mfaEnrollStartMutation = useMutation({ + mutationFn: async (token: string) => { + const { data } = await apiClient.post("/auth/mfa/enroll", { + mfa_enrollment_token: token, + }); + return data; + }, + onSuccess: (data) => { + setEnrollData(data); + setEnrollmentError(null); + }, + onError: (error: unknown) => { + setEnrollmentError( + getApiErrorMessage(error, "Failed to start MFA setup. Please sign in again.") + ); + }, + }); + + const mfaEnrollVerifyMutation = useMutation({ + mutationFn: async ({ token, code }: { token: string; code: string }) => { + const { data } = await apiClient.post("/auth/mfa/enroll/verify", { + mfa_enrollment_token: token, + code, + }); + return data; + }, + onSuccess: handleLoginSuccess, + onError: (error: unknown) => { + setEnrollmentError( + getApiErrorMessage(error, "Invalid verification code. Please try again.") + ); + }, + }); + + const submitPasswordLogin = (password: string, formEmail?: string) => { + const email = getEmailForSubmit(formEmail); + const emailErr = validateEmail(email); + if (emailErr) { + if (showCredentialTabs) { + setSharedEmailError(emailErr); + } else { + passwordForm.setError("email", { message: emailErr }); + } + return; + } + setSharedEmailError(null); + loginMutation.mutate({ email, password }); + }; + + const submitMagicLink = (formEmail?: string) => { + const email = getEmailForSubmit(formEmail); + const emailErr = validateEmail(email); + if (emailErr) { + if (showCredentialTabs) { + setSharedEmailError(emailErr); + } else { + magicForm.setError("email", { message: emailErr }); + } + return; + } + setSharedEmailError(null); + magicLinkMutation.mutate(email); + }; + const handleWebAuthnLogin = async () => { + setPasskeyError(null); setIsWebAuthnLoading(true); try { - const { data } = await apiClient.post( - "/auth/webauthn/authenticate/begin", - {} - ); - - const assertion = await startAuthentication({ - optionsJSON: data.options, + const { data } = await apiClient.post("/auth/webauthn/authenticate/begin", { + tenant_id: PLATFORM_TENANT_ID || undefined, }); - const credentialPayload = { - id: assertion.id, - rawId: assertion.rawId, - type: assertion.type, - response: { - clientDataJSON: assertion.response.clientDataJSON, - authenticatorData: assertion.response.authenticatorData, - signature: assertion.response.signature, - userHandle: assertion.response.userHandle, - }, - }; + const assertion = await startAuthentication({ optionsJSON: data.options }); const { data: loginData } = await apiClient.post( "/auth/webauthn/authenticate/complete", - { credential: credentialPayload } + { + credential: { + id: assertion.id, + rawId: assertion.rawId, + type: assertion.type, + response: { + clientDataJSON: assertion.response.clientDataJSON, + authenticatorData: assertion.response.authenticatorData, + signature: assertion.response.signature, + userHandle: assertion.response.userHandle, + }, + }, + tenant_id: PLATFORM_TENANT_ID || undefined, + } ); handleLoginSuccess(loginData); } catch (error: unknown) { if (isNotAllowedError(error)) { - toast.error("Passkey authentication cancelled."); - } else { - toast.error(getApiErrorMessage(error, "Passkey login failed.")); + // User dismissed the prompt or the browser timed out — no error banner. + return; } + setPasskeyError(getApiErrorMessage(error, "Passkey login failed.")); } finally { setIsWebAuthnLoading(false); } }; - const handleSocialLogin = (provider: string) => { - const baseUrl = - process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; - - window.location.href = `${baseUrl}/auth/oauth/${provider}/login`; + const handleSocialLogin = (provider: string, tenantId: string) => { + const baseUrl = process.env.NEXT_PUBLIC_API_URL; + const params = new URLSearchParams({ tenant_id: tenantId }); + window.location.href = `${baseUrl}/auth/oauth/${provider}/login?${params.toString()}`; }; - const onSubmit = (values: z.infer) => - loginMutation.mutate(values); + const sharedEmailField = showCredentialTabs ? ( +
+ + { + setSharedEmail(e.target.value); + setSharedEmailError(null); + setMagicLinkSentEmail(null); + }} + /> + {sharedEmailError ? ( +

{sharedEmailError}

+ ) : null} +
+ ) : null; + + const passwordRootError = passwordForm.formState.errors.root?.message; + const magicRootError = magicForm.formState.errors.root?.message; - if (mfaPendingToken) { + const hasSocialProviders = showSocial && socialProviders.length > 0; + const showSocialSection = showSocial && (hasSocialProviders || isLoadingSocialProviders); + const hasPrimaryAuth = showEmailField; + + if (mfaEnrollmentToken) { return ( -
- - - - Two-Factor Verification - - Enter the 6-digit code from your authenticator app. - - - - - - setMfaCode(e.target.value.replace(/\D/g, "")) - } - className="text-center text-xl tracking-[0.4em]" - /> - - - + +
+ {!enrollData ? ( + <> +
+ +
+ {enrollmentError ? : null} + + + ) : ( + <> + {enrollmentQrCode ? ( +
+
+ MFA QR Code +
+
+ ) : null} +

+ Manual code: {enrollData.secret} +

+ { + setEnrollmentCode(e.target.value.replace(/\D/g, "")); + setEnrollmentError(null); + }} + className="h-12 text-center text-xl tracking-[0.45em]" + /> + {enrollmentError ? : null} + + + )} + +
+
+ ); + } - - - -
-
+ if (mfaPendingToken) { + return ( + +
+
+ +
+ { + setMfaCode(e.target.value.replace(/\D/g, "")); + setMfaError(null); + }} + className="h-12 text-center text-xl tracking-[0.45em]" + /> + {mfaError ? : null} + + +
+
); } + const signUpFooter = ( + <> + Don't have an account?{" "} + + Sign up + + + ); + return ( -
- - - Sign In - - Access your account securely - - - - - - {/* EMAIL LOGIN */} -
- - - ( - - Email - - - - - - )} - /> + + {isLoadingAuthConfig ? ( + + ) : ( +
+ {showCredentialTabs ? ( +
+ {sharedEmailField} + + + Password + Magic link + - ( - -
- Password - + + + submitPasswordLogin(values.password) + )} + className="space-y-4" + > + ( + +
+ Password + + Forgot password? + +
+ +
+ + +
+
+ +
+ )} + /> + {passwordRootError ? ( + + ) : null} +
- -
+ {loginMutation.isPending && ( + + )} + Sign in + + + + + + + {magicLinkSentEmail ? ( + submitMagicLink(magicLinkSentEmail)} + onChangeEmail={() => { + setMagicLinkSentEmail(null); + setResendCooldown(0); + }} + /> + ) : ( + <> +

+ We'll email a secure one-time link — no password + needed. +

+ {magicRootError ? ( + + ) : null} + + + )} +
+ +
+ ) : showEmailPassword ? ( +
+ + submitPasswordLogin(values.password, values.email) + )} + className="space-y-4" + > + ( + + Email + - + Forgot password? +
- - - )} + +
+ + +
+
+ + + )} + /> + {passwordRootError ? ( + + ) : null} + + + + ) : showMagicLink ? ( + magicLinkSentEmail ? ( + submitMagicLink(magicLinkSentEmail)} + onChangeEmail={() => { + setMagicLinkSentEmail(null); + setResendCooldown(0); + }} /> + ) : ( +
+ + submitMagicLink(values.email) + )} + className="space-y-4" + > +

+ Enter your email and we'll send a one-time sign-in link. +

+ ( + + Email + + + + + + )} + /> + {magicRootError ? ( + + ) : null} + + + + ) + ) : null} - - - - - {/* DIVIDER */} -
-
- -
-
- - Or continue with - +

+ Use Touch ID, Face ID, Windows Hello, or a security key on this device. +

+ {passkeyError ? : null}
-
+ )} - {/* PASSKEY */} - + {(hasPrimaryAuth || showPasskeySection) && showSocialSection && ( + + )} - {/* SOCIAL */} -
- - - - - - - - + {!hasPrimaryAuth && !showPasskeySection && showSocialSection && ( +

+ Continue with a connected account +

+ )} -
- - - - Don't have an account?{" "} - - Sign up - - - -
+ {showSocialSection && ( +
+ {isLoadingSocialProviders && ( +
+ +
+ )} + + {hasSocialProviders && ( +
+ {socialProviders.map(({ provider, tenant_id }) => { + const meta = SOCIAL_PROVIDER_META[provider]; + const Icon = meta?.icon; + return ( + + ); + })} +
+ )} +
+ )} + + {!hasPrimaryAuth && !showPasskeySection && !showSocialSection && ( +

+ No login methods are enabled for this organization. +

+ )} +
+ )} + ); } @@ -359,11 +1045,11 @@ export default function LoginPage() { - +
} > ); -} \ No newline at end of file +} diff --git a/src/app/(dashboard)/platform/audit/page.tsx b/src/app/(dashboard)/platform/audit/page.tsx index 1a1e14a..382069b 100644 --- a/src/app/(dashboard)/platform/audit/page.tsx +++ b/src/app/(dashboard)/platform/audit/page.tsx @@ -5,7 +5,6 @@ import { apiClient } from "@/lib/api-client"; import { AuditLogEntry } from "@/lib/types"; import { Search, - Filter, Clock, Activity, ArrowRight, @@ -26,14 +25,21 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { useMemo, useState } from "react"; +import { normalizeAuditLogs } from "@/lib/audit"; +import { AuditLogDetailsDialog } from "@/components/audit/audit-log-details-dialog"; export default function PlatformAuditPage() { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedLog, setSelectedLog] = useState(null); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + // 1. Fetch Audit Logs const { data: logs, isLoading } = useQuery({ queryKey: ["globalAudit"], queryFn: async () => { const { data } = await apiClient.get("/platform/audit-logs"); - return data; + return normalizeAuditLogs(data); }, }); @@ -44,6 +50,29 @@ export default function PlatformAuditPage() { return "text-muted-foreground border-muted bg-muted/5"; }; + const filteredLogs = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) return logs ?? []; + + return (logs ?? []).filter((log) => + log.action?.toLowerCase().includes(term) || + log.actor_id?.toLowerCase().includes(term) || + log.ip_address?.toLowerCase().includes(term) || + log.resource?.toLowerCase().includes(term) || + log.resource_type?.toLowerCase().includes(term) || + log.resource_id?.toLowerCase().includes(term) || + log.target_user_id?.toLowerCase().includes(term) || + log.status?.toLowerCase().includes(term) || + log.tenant_id?.toLowerCase().includes(term) || + log.user_agent?.toLowerCase().includes(term) + ); + }, [logs, searchTerm]); + + const openDetails = (log: AuditLogEntry) => { + setSelectedLog(log); + setIsDetailsOpen(true); + }; + return (
@@ -61,11 +90,13 @@ export default function PlatformAuditPage() {
- + setSearchTerm(e.target.value)} + />
-
@@ -85,16 +116,16 @@ export default function PlatformAuditPage() { - {logs?.map((log) => ( + {filteredLogs.map((log) => (
{log.action.replace(/_/g, ' ')} - {log.resource_type && ( + {(log.resource ?? log.resource_type) && (

- on {log.resource_type} + on {log.resource ?? log.resource_type}

)}
@@ -118,16 +149,24 @@ export default function PlatformAuditPage() {
- ))} - {(!logs || logs.length === 0) && ( + {filteredLogs.length === 0 && ( - No audit logs found. + {searchTerm.trim() + ? "No audit logs match your search." + : "No audit logs found."} )} @@ -135,6 +174,16 @@ export default function PlatformAuditPage() { )} + + { + setIsDetailsOpen(open); + if (!open) setSelectedLog(null); + }} + getActionColor={getActionColor} + />
); } diff --git a/src/app/(dashboard)/platform/leads/page.tsx b/src/app/(dashboard)/platform/leads/page.tsx index 6ff5197..8ab945c 100644 --- a/src/app/(dashboard)/platform/leads/page.tsx +++ b/src/app/(dashboard)/platform/leads/page.tsx @@ -5,7 +5,6 @@ import { apiClient } from "@/lib/api-client"; import { ContactLead } from "@/lib/types"; import { Search, - Filter, Clock, Loader2, Mail, @@ -22,11 +21,13 @@ import { TableRow, } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { useMemo, useState } from "react"; export default function PlatformLeadsPage() { + const [searchTerm, setSearchTerm] = useState(""); + const { data: leads, isLoading } = useQuery({ queryKey: ["platformContactLeads"], queryFn: async () => { @@ -35,6 +36,25 @@ export default function PlatformLeadsPage() { }, }); + const filteredLeads = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) return leads ?? []; + + return (leads ?? []).filter((lead) => { + const fullName = `${lead.first_name} ${lead.last_name}`.toLowerCase(); + return ( + fullName.includes(term) || + lead.email?.toLowerCase().includes(term) || + lead.company?.toLowerCase().includes(term) || + lead.phone?.toLowerCase().includes(term) || + lead.job_title?.toLowerCase().includes(term) || + lead.interest?.toLowerCase().includes(term) || + lead.country?.toLowerCase().includes(term) || + lead.message?.toLowerCase().includes(term) + ); + }); + }, [leads, searchTerm]); + return (
@@ -45,18 +65,20 @@ export default function PlatformLeadsPage() {

- {leads?.length ?? 0} leads + {filteredLeads.length} leads
- + setSearchTerm(e.target.value)} + />
-
@@ -76,7 +98,7 @@ export default function PlatformLeadsPage() { - {leads?.map((lead) => ( + {filteredLeads.map((lead) => (
@@ -160,10 +182,12 @@ export default function PlatformLeadsPage() { ))} - {(!leads || leads.length === 0) && ( + {filteredLeads.length === 0 && ( - No contact leads yet. + {searchTerm.trim() + ? "No leads match your search." + : "No contact leads yet."} )} diff --git a/src/app/(dashboard)/platform/page.tsx b/src/app/(dashboard)/platform/page.tsx index a6454ee..ba80741 100644 --- a/src/app/(dashboard)/platform/page.tsx +++ b/src/app/(dashboard)/platform/page.tsx @@ -3,15 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { TenantResponse } from "@/lib/types"; -import { - Building2, - Users2, - Activity, - TrendingUp, - Server, - Database, - Globe -} from "lucide-react"; +import { Building2, Users2, Activity } from "lucide-react"; import { Card, @@ -21,10 +13,8 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Progress } from "@/components/ui/progress"; export default function PlatformOverviewPage() { - // 1. Fetch Global Stats const { data: tenants } = useQuery({ queryKey: ["allTenants"], queryFn: async () => { @@ -59,9 +49,6 @@ export default function PlatformOverviewPage() {
{tenants?.length || 0}
-
- +12% from last month -
@@ -72,9 +59,6 @@ export default function PlatformOverviewPage() {
{users?.length || 0}
-
- +5.2% from last month -
@@ -83,53 +67,19 @@ export default function PlatformOverviewPage() { System Health - -
Optimal
-

API Latency: 42ms

-
+ Storage Used - - -
12.4 GB
- -
+
-
- {/* System Info */} - - - - - System Information - - - -
- Engine Version - v1.4.2-stable -
-
- Environment - Production -
-
- Region - - US-East-1 - -
-
-
- - {/* Recent Tenants */} - + {/* Recent Tenants */} + @@ -158,7 +108,6 @@ export default function PlatformOverviewPage() {
-
); } diff --git a/src/app/(dashboard)/platform/roles/page.tsx b/src/app/(dashboard)/platform/roles/page.tsx index b894f72..ddc88c8 100644 --- a/src/app/(dashboard)/platform/roles/page.tsx +++ b/src/app/(dashboard)/platform/roles/page.tsx @@ -43,6 +43,8 @@ export default function PlatformRolesPage() { const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingRole, setEditingRole] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deletingRole, setDeletingRole] = useState(null); // Form State const [formData, setFormData] = useState({ @@ -71,8 +73,8 @@ export default function PlatformRolesPage() { }); // Derived - const platformRoles = roles.filter((r) => r.scope === "PLATFORM"); - const tenantRoles = roles.filter((r) => r.scope === "TENANT"); + const platformRoles = roles.filter((r) => r.scope === "PLATFORM" && !r.is_template); + const tenantRoles = roles.filter((r) => r.scope === "TENANT" && r.is_template); // Filtered permissions based on tab/scope const availablePerms = useMemo(() => { @@ -85,7 +87,6 @@ export default function PlatformRolesPage() { }, [permissions, formData.scope]); const handleOpenCreate = () => { - toast.info("Opening role creation..."); setEditingRole(null); setFormData({ name: "", @@ -98,7 +99,6 @@ export default function PlatformRolesPage() { }; const handleOpenEdit = (role: Role) => { - toast.info(`Editing role: ${role.name}`); setEditingRole(role); setFormData({ name: role.name, @@ -137,6 +137,8 @@ export default function PlatformRolesPage() { }, onSuccess: () => { toast.success("Role deleted!"); + setIsDeleting(false); + setDeletingRole(null); queryClient.invalidateQueries({ queryKey: ["globalRoles"] }); }, onError: (err: unknown) => { @@ -164,76 +166,76 @@ export default function PlatformRolesPage() { }); }; - const isSystemRole = (name: string) => ["SUPER_ADMIN", "PLATFORM_ADMIN", "TENANT_OWNER"].includes(name); + const openDeleteDialog = (role: Role) => { + setDeletingRole(role); + setIsDeleting(true); + }; const renderRoleCard = (role: Role) => ( - - -
-
+ + +
+
{role.scope} - {isSystemRole(role.name) && ( + {role.is_system_role && ( SYSTEM )}
+ + Lvl {role.level} +
- {role.name} - + {role.name} + {role.description || "No description provided."}
- -
-

- Permissions -

-
- {!role.permissions?.length && ( - None assigned - )} - {role.permissions?.slice(0, 5).map((p) => ( - - {p.name} - - ))} - {role.permissions?.length > 5 && ( - - +{role.permissions.length - 5} more - - )} -
-
-
-

Level

-

{role.level}

+ +

+ Permissions +

+
+ {!role.permissions?.length && ( + None assigned + )} + {role.permissions?.slice(0, 4).map((p) => ( + + {p.name} + + ))} + {role.permissions && role.permissions.length > 4 && ( + + +{role.permissions.length - 4} more + + )}
-
- {role.id.slice(0, 8)}... -
- + {!role.is_system_role && ( + - {!isSystemRole(role.name) && ( - - )} -
+ )}
); @@ -245,7 +247,7 @@ export default function PlatformRolesPage() {

Roles & Permissions

- Define platform and tenant roles across the system. + Manage platform roles and default tenant role templates.

@@ -262,24 +264,24 @@ export default function PlatformRolesPage() { ) : ( - Tenant Roles + Tenant Templates Platform Roles -
+
{tenantRoles.map(renderRoleCard)}
{tenantRoles.length === 0 && (
-

No tenant roles found.

+

No tenant role templates found.

)} -
+
{platformRoles.map(renderRoleCard)}
{platformRoles.length === 0 && ( @@ -292,8 +294,8 @@ export default function PlatformRolesPage() { )} - - + + {editingRole ? "Edit Role" : "Create Role"} {editingRole @@ -302,8 +304,7 @@ export default function PlatformRolesPage() { - -
+
setFormData({ ...formData, name: e.target.value })} placeholder="e.g. EDITOR" - disabled={!!editingRole && isSystemRole(editingRole.name)} + disabled={!!editingRole?.is_name_locked} className="uppercase" /> - {editingRole && isSystemRole(editingRole.name) && ( -

System role names cannot be changed.

+ {editingRole?.is_name_locked && ( +

Platform system role names cannot be changed.

)}
@@ -372,7 +373,7 @@ export default function PlatformRolesPage() { Only permissions matching the {formData.scope} scope (or globally accessible auth traits) can be assigned.

-
+
{availablePerms.map((p) => { const checked = formData.permissions.includes(p.id); return ( @@ -400,7 +401,7 @@ export default function PlatformRolesPage() {
- + @@ -411,6 +412,29 @@ export default function PlatformRolesPage() { + + {/* Delete Confirmation Dialog */} + + + + Delete Role + + Are you sure you want to delete {deletingRole?.name}? This action is irreversible and may affect users assigned to this role. + + + + + + + +
); } diff --git a/src/app/(dashboard)/platform/service-keys/page.tsx b/src/app/(dashboard)/platform/service-keys/page.tsx index 44a39ee..c0474b9 100644 --- a/src/app/(dashboard)/platform/service-keys/page.tsx +++ b/src/app/(dashboard)/platform/service-keys/page.tsx @@ -8,7 +8,7 @@ import { useState } from "react"; import { Key, Plus, - Trash2, + Ban, Loader2, Copy, Eye, @@ -40,14 +40,18 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; export default function ServiceKeysPage() { const queryClient = useQueryClient(); const [isCreating, setIsCreating] = useState(false); - const [description, setDescription] = useState(""); + const [serviceName, setServiceName] = useState(""); const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); const [showKey, setShowKey] = useState(false); + const [isRevoking, setIsRevoking] = useState(false); + const [revokingKey, setRevokingKey] = useState(null); // Fetch service keys const { data: keys, isLoading } = useQuery({ @@ -60,16 +64,16 @@ export default function ServiceKeysPage() { // Create service key const createMutation = useMutation({ - mutationFn: async (desc: string) => { + mutationFn: async (name: string) => { const { data } = await apiClient.post("/auth/service-keys", { - description: desc || undefined, + service_name: name, }); return data; }, onSuccess: (data: ServiceKeyResponse) => { setNewlyCreatedKey(data.raw_key || data.key_prefix); setShowKey(true); - setDescription(""); + setServiceName(""); toast.success("Service key created! Copy it now — it won't be shown again."); queryClient.invalidateQueries({ queryKey: ["serviceKeys"] }); }, @@ -85,6 +89,8 @@ export default function ServiceKeysPage() { }, onSuccess: () => { toast.success("Service key revoked"); + setIsRevoking(false); + setRevokingKey(null); queryClient.invalidateQueries({ queryKey: ["serviceKeys"] }); }, onError: (error: unknown) => { @@ -97,6 +103,11 @@ export default function ServiceKeysPage() { toast.success("Copied to clipboard"); }; + const openRevokeDialog = (key: ServiceKeyResponse) => { + setRevokingKey(key); + setIsRevoking(true); + }; + return (
@@ -150,9 +161,14 @@ export default function ServiceKeysPage() { variant="ghost" size="icon" className="h-7 w-7" + title={showKey ? "Hide key" : "Show key"} onClick={() => setShowKey(!showKey)} > - {showKey ? : } + {showKey ? ( + + ) : ( + + )}
)} @@ -184,8 +206,8 @@ export default function ServiceKeysPage() { ) : (
- - {key.description || "No description"} + + {key.service_name} + + + {key.is_active ? "Active" : "Revoked"} + + + +
+

+ {key.creator?.email ?? "Unknown"} +

+ {key.created_by && !key.creator?.email && ( +

+ {key.created_by.slice(0, 8)}... +

+ )} +
+
@@ -248,18 +296,18 @@ export default function ServiceKeysPage() { - + {key.is_active ? ( + + ) : ( + + )} ))} @@ -268,13 +316,50 @@ export default function ServiceKeysPage() { )} + + + + Revoke Service Key + +
+

+ Revoke {revokingKey?.service_name}{" "} + ({revokingKey?.key_prefix})? +

+

+ The key will stop working immediately. Any service using it will receive{" "} + 401 Unauthorized. +

+

+ This cannot be undone — generate a new key if you need access again. The revoked + record is kept for audit purposes (there is no separate delete action). +

+
+
+
+ + + + +
+
+

About Service Keys

Service keys provide machine-to-machine access to the platform API. Keys are hashed at rest — - only the prefix is stored for identification. Requires platform.tenants.manage permission. + only the prefix is stored for identification. Use Revoke to + permanently disable a key; revoked keys remain in the list for audit. To restore access, generate a + new key. Requires platform.tenants.manage permission.

diff --git a/src/app/(dashboard)/platform/tenants/[id]/page.tsx b/src/app/(dashboard)/platform/tenants/[id]/page.tsx new file mode 100644 index 0000000..bba9be3 --- /dev/null +++ b/src/app/(dashboard)/platform/tenants/[id]/page.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; +import { getApiErrorMessage } from "@/lib/errors"; +import { TenantResponse, UserResponse } from "@/lib/types"; +import { useParams, useRouter } from "next/navigation"; +import { + ArrowLeft, + Building2, + User, + Calendar, + Pencil, + Loader2, + Check, + ChevronsUpDown, + Search, + Save, +} from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from "sonner"; +import { useState } from "react"; + +interface TenantPayload { + name: string; + description: string; + type: string; + owner_id: string; +} + +export default function TenantDetailPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + const queryClient = useQueryClient(); + const [isEditing, setIsEditing] = useState(false); + const [form, setForm] = useState({ name: "", description: "", type: "", owner_id: "" }); + const [ownerSearch, setOwnerSearch] = useState(""); + const [selectedOwner, setSelectedOwner] = useState(null); + const [isOwnerDropdownOpen, setIsOwnerDropdownOpen] = useState(false); + + const { data: tenant, isLoading } = useQuery({ + queryKey: ["tenant", params.id], + queryFn: async () => { + const { data } = await apiClient.get(`/platform/tenants/${params.id}`); + return data; + }, + enabled: !!params.id, + }); + + const { data: users } = useQuery({ + queryKey: ["platformUsers"], + queryFn: async () => { + const { data } = await apiClient.get("/platform/users"); + return data; + }, + }); + + const filteredOwners = users?.filter((u) => + u.email.toLowerCase().includes(ownerSearch.toLowerCase()) || + `${u.first_name} ${u.last_name}`.toLowerCase().includes(ownerSearch.toLowerCase()) + ); + + const updateMutation = useMutation({ + mutationFn: async (payload: TenantPayload) => { + const { data } = await apiClient.put(`/platform/tenants/${params.id}`, { + name: payload.name, + description: payload.description, + owner_id: payload.owner_id, + }); + return data; + }, + onSuccess: () => { + toast.success("Organization updated successfully"); + setIsEditing(false); + queryClient.invalidateQueries({ queryKey: ["tenant", params.id] }); + queryClient.invalidateQueries({ queryKey: ["allTenants"] }); + }, + onError: (error: unknown) => { + toast.error(getApiErrorMessage(error, "Failed to update organization")); + }, + }); + + const openEditDialog = () => { + if (!tenant) return; + setForm({ + name: tenant.name, + description: tenant.description || "", + type: tenant.type, + owner_id: tenant.owner_id, + }); + const existingOwner = users?.find((u) => u.id === tenant.owner_id); + setSelectedOwner(existingOwner || null); + setOwnerSearch(""); + setIsEditing(true); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!tenant) { + return ( +
+ Tenant not found. +
+ ); + } + + return ( +
+ {/* Edit Dialog */} + + + + Edit Organization + Update the details for {tenant.name}. + + +
+
+ + setForm({ ...form, name: e.target.value })} + /> +
+
+ + setForm({ ...form, description: e.target.value })} + /> +
+
+ + {tenant.type === "PLATFORM" ? ( +

+ System platform tenant (cannot be changed) +

+ ) : ( +

Customer organization

+ )} +
+
+ + + + + + +
+
+ + setOwnerSearch(e.target.value)} + className="pl-8 h-9 text-xs" + /> +
+
+
+ {filteredOwners?.map((user) => ( + { + setSelectedOwner(user); + setForm({ ...form, owner_id: user.id }); + setIsOwnerDropdownOpen(false); + }} + className="flex items-center justify-between py-2 cursor-pointer" + > +
+
+ {user.first_name?.charAt(0) || user.email.charAt(0).toUpperCase()} +
+
+ {user.first_name} {user.last_name} + {user.email} +
+
+ {selectedOwner?.id === user.id && } +
+ ))} + {filteredOwners?.length === 0 && ( +
No users found.
+ )} +
+
+
+
+
+ + + + + +
+
+ + {/* Page Header */} +
+ +
+

{tenant.name}

+

{tenant.id}

+
+ +
+ +
+ {/* Details Card */} + + + + + Organization Details + + Core information about this tenant. + + + + + + {tenant.type} + + + Active + + + + +
+ {/* Owner Card */} + + + + + Owner + + + + {tenant.owner ? ( +
+
+ {tenant.owner.first_name?.charAt(0) || tenant.owner.email.charAt(0).toUpperCase()} +
+
+

+ {tenant.owner.first_name} {tenant.owner.last_name} +

+

{tenant.owner.email}

+
+
+ ) : ( +

{tenant.owner_id}

+ )} +
+
+ + {/* Timestamps Card */} + + + + + Timestamps + + + + + + + +
+
+
+ ); +} + +function Row({ + label, + value, + children, +}: { + label: string; + value?: string; + children?: React.ReactNode; +}) { + return ( +
+ {label} + {children ?? {value}} +
+ ); +} diff --git a/src/app/(dashboard)/platform/tenants/page.tsx b/src/app/(dashboard)/platform/tenants/page.tsx index 681a395..c5c9abd 100644 --- a/src/app/(dashboard)/platform/tenants/page.tsx +++ b/src/app/(dashboard)/platform/tenants/page.tsx @@ -4,14 +4,13 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { getApiErrorMessage } from "@/lib/errors"; import { TenantResponse, UserResponse } from "@/lib/types"; +import { useRouter } from "next/navigation"; import { - Globe, Check, ChevronsUpDown, Plus, Search, MoreVertical, - ExternalLink, Loader2, Trash2, Pencil, @@ -51,10 +50,7 @@ import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { useState } from "react"; -interface TenantListItem extends TenantResponse { - created_at?: string; - owner?: Pick; -} +type TenantListItem = TenantResponse; interface TenantPayload { name: string; @@ -65,6 +61,7 @@ interface TenantPayload { export default function PlatformTenantsPage() { const queryClient = useQueryClient(); + const router = useRouter(); const [isAdding, setIsAdding] = useState(false); const [isEditing, setIsEditing] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -150,7 +147,11 @@ export default function PlatformTenantsPage() { // 4. Update Tenant const updateMutation = useMutation({ mutationFn: async ({ id, payload }: { id: string; payload: TenantPayload }) => { - await apiClient.put(`/platform/tenants/${id}`, payload); + await apiClient.put(`/platform/tenants/${id}`, { + name: payload.name, + description: payload.description, + owner_id: payload.owner_id, + }); }, onSuccess: () => { toast.success("Organization updated successfully"); @@ -179,19 +180,6 @@ export default function PlatformTenantsPage() { } }); - const openEditDialog = (tenant: TenantListItem) => { - setEditingTenant(tenant); - setEditForm({ - name: tenant.name, - description: tenant.description || "", - type: tenant.type, - owner_id: tenant.owner_id || "" - }); - const owner = users?.find((u) => u.id === tenant.owner_id); - setEditSelectedUser(owner || null); - setIsEditing(true); - }; - const openDeleteDialog = (tenant: TenantListItem) => { setDeletingTenant(tenant); setIsDeleting(true); @@ -238,17 +226,6 @@ export default function PlatformTenantsPage() { onChange={(e) => setNewTenant({ ...newTenant, description: e.target.value })} />
-
- - -
@@ -357,14 +334,13 @@ export default function PlatformTenantsPage() {
- + {editingTenant?.type === "PLATFORM" ? ( +

+ System platform tenant (cannot be changed) +

+ ) : ( +

Customer organization

+ )}
@@ -528,25 +504,23 @@ export default function PlatformTenantsPage() { Tenant Admin - - View Dashboard - openEditDialog(t)} - > - Edit Organization - - - Visit Login Page - - - openDeleteDialog(t)} + onClick={() => router.push(`/platform/tenants/${t.id}`)} > - Delete Tenant + View / Edit + {t.type !== "PLATFORM" && ( + <> + + openDeleteDialog(t)} + > + Delete Tenant + + + )} diff --git a/src/app/(dashboard)/platform/users/[userId]/page.tsx b/src/app/(dashboard)/platform/users/[userId]/page.tsx index f5b4075..1e6ff8f 100644 --- a/src/app/(dashboard)/platform/users/[userId]/page.tsx +++ b/src/app/(dashboard)/platform/users/[userId]/page.tsx @@ -3,9 +3,9 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { getApiErrorMessage } from "@/lib/errors"; -import { Role, UserResponse } from "@/lib/types"; +import { Role, TenantResponse, UserResponse } from "@/lib/types"; import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { ArrowLeft, Loader2, @@ -48,16 +48,25 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Label } from "@/components/ui/label"; import { toast } from "sonner"; import Link from "next/link"; +type RemoveRoleTarget = + | { scope: "platform"; roleName: string } + | { scope: "tenant"; roleName: string; roleId: string; tenantId: string; tenantName: string }; + export default function PlatformUserDetailPage() { const { userId } = useParams<{ userId: string }>(); const router = useRouter(); const queryClient = useQueryClient(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const [isAssignRoleOpen, setIsAssignRoleOpen] = useState(false); - const [selectedRole, setSelectedRole] = useState(""); + const [isAssignPlatformRoleOpen, setIsAssignPlatformRoleOpen] = useState(false); + const [isAssignTenantRoleOpen, setIsAssignTenantRoleOpen] = useState(false); + const [selectedPlatformRoleId, setSelectedPlatformRoleId] = useState(""); + const [selectedTenantId, setSelectedTenantId] = useState(""); + const [selectedTenantRoleId, setSelectedTenantRoleId] = useState(""); + const [removeRoleTarget, setRemoveRoleTarget] = useState(null); // Fetch user details const { data: user, isLoading } = useQuery({ @@ -69,15 +78,37 @@ export default function PlatformUserDetailPage() { enabled: !!userId, }); - // Fetch available platform roles - const { data: roles } = useQuery({ - queryKey: ["globalRoles"], + // Fetch platform roles only (not tenant templates) + const { data: platformRoles = [] } = useQuery({ + queryKey: ["platformRoles"], + queryFn: async () => { + const { data } = await apiClient.get("/platform/roles?scope=PLATFORM"); + return data; + }, + }); + + const { data: tenants = [] } = useQuery({ + queryKey: ["platformTenants"], + queryFn: async () => { + const { data } = await apiClient.get("/platform/tenants"); + return data.filter((t) => t.type !== "PLATFORM"); + }, + }); + + const { data: tenantRoles = [], isLoading: isLoadingTenantRoles } = useQuery({ + queryKey: ["platformTenantRoles", selectedTenantId], queryFn: async () => { - const { data } = await apiClient.get("/platform/roles"); + const { data } = await apiClient.get(`/platform/tenants/${selectedTenantId}/roles`); return data; }, + enabled: !!selectedTenantId, }); + const tenantNameById = useMemo( + () => Object.fromEntries(tenants.map((t) => [t.id, t.name])), + [tenants] + ); + // Update user status const updateStatusMutation = useMutation({ mutationFn: async (status: string) => { @@ -107,33 +138,75 @@ export default function PlatformUserDetailPage() { }, }); - // Assign role - const assignRoleMutation = useMutation({ - mutationFn: async (roleName: string) => { - await apiClient.post(`/platform/users/${userId}/roles`, { role_name: roleName }); + // Assign platform role + const assignPlatformRoleMutation = useMutation({ + mutationFn: async (roleId: string) => { + const role = platformRoles.find((r) => r.id === roleId); + await apiClient.post(`/platform/users/${userId}/roles`, { + role_id: roleId, + role_name: role?.name, + }); + }, + onSuccess: () => { + toast.success("Platform role assigned"); + setIsAssignPlatformRoleOpen(false); + setSelectedPlatformRoleId(""); + queryClient.invalidateQueries({ queryKey: ["platformUser", userId] }); + }, + onError: (error: unknown) => { + toast.error(getApiErrorMessage(error, "Failed to assign platform role")); + }, + }); + + // Assign tenant role + const assignTenantRoleMutation = useMutation({ + mutationFn: async () => { + await apiClient.post(`/platform/users/${userId}/tenant-roles`, { + tenant_id: selectedTenantId, + role_id: selectedTenantRoleId, + }); }, onSuccess: () => { - toast.success("Role assigned successfully"); - setIsAssignRoleOpen(false); - setSelectedRole(""); + toast.success("Tenant role assigned"); + setIsAssignTenantRoleOpen(false); + setSelectedTenantId(""); + setSelectedTenantRoleId(""); queryClient.invalidateQueries({ queryKey: ["platformUser", userId] }); }, onError: (error: unknown) => { - toast.error(getApiErrorMessage(error, "Failed to assign role")); + toast.error(getApiErrorMessage(error, "Failed to assign tenant role")); }, }); - // Remove role - const removeRoleMutation = useMutation({ + // Remove platform role + const removePlatformRoleMutation = useMutation({ mutationFn: async (roleName: string) => { await apiClient.delete(`/platform/users/${userId}/roles/${roleName}`); }, onSuccess: () => { - toast.success("Role removed"); + toast.success("Platform role removed"); + setRemoveRoleTarget(null); + queryClient.invalidateQueries({ queryKey: ["platformUser", userId] }); + }, + onError: (error: unknown) => { + toast.error(getApiErrorMessage(error, "Failed to remove platform role")); + }, + }); + + // Remove tenant role + const removeTenantRoleMutation = useMutation({ + mutationFn: async ({ tenantId, roleId }: { tenantId: string; roleId: string }) => { + await apiClient.delete(`/platform/users/${userId}/tenant-roles`, { + params: { tenant_id: tenantId, role_id: roleId }, + }); + }, + onSuccess: () => { + toast.success("Tenant role removed"); + setRemoveRoleTarget(null); queryClient.invalidateQueries({ queryKey: ["platformUser", userId] }); }, onError: (error: unknown) => { - toast.error(getApiErrorMessage(error, "Failed to remove role")); + toast.error(getApiErrorMessage(error, "Failed to remove tenant role")); }, }); @@ -158,10 +231,31 @@ export default function PlatformUserDetailPage() { const statusOptions = ["ACTIVE", "INACTIVE", "SUSPENDED"]; - // Get user's platform roles const userPlatformRoles = user.roles?.filter((r) => r.role?.scope === "PLATFORM") || []; - const assignedRoleNames = userPlatformRoles.map((r) => r.role?.name); - const availableRoles = roles?.filter((r) => !assignedRoleNames.includes(r.name)) || []; + const userTenantRoles = user.roles?.filter((r) => r.role?.scope === "TENANT") || []; + const assignedPlatformRoleIds = new Set(userPlatformRoles.map((r) => r.role?.id)); + const assignedTenantRoleKeys = new Set( + userTenantRoles.map((r) => `${r.tenant_id}:${r.role?.id}`) + ); + const availablePlatformRoles = platformRoles.filter((r) => !assignedPlatformRoleIds.has(r.id)); + const availableTenantRoles = tenantRoles.filter( + (r) => !assignedTenantRoleKeys.has(`${selectedTenantId}:${r.id}`) + ); + + const handleConfirmRemoveRole = () => { + if (!removeRoleTarget) return; + if (removeRoleTarget.scope === "platform") { + removePlatformRoleMutation.mutate(removeRoleTarget.roleName); + } else { + removeTenantRoleMutation.mutate({ + tenantId: removeRoleTarget.tenantId, + roleId: removeRoleTarget.roleId, + }); + } + }; + + const isRemoveRolePending = + removePlatformRoleMutation.isPending || removeTenantRoleMutation.isPending; return (
@@ -371,7 +465,7 @@ export default function PlatformUserDetailPage() { Platform Roles - +
@@ -444,11 +539,12 @@ export default function PlatformUserDetailPage() { variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive" - onClick={() => { - if (confirm(`Remove role "${ra.role.name}" from this user?`)) { - removeRoleMutation.mutate(ra.role.name); - } - }} + onClick={() => + setRemoveRoleTarget({ + scope: "platform", + roleName: ra.role.name, + }) + } > @@ -458,36 +554,183 @@ export default function PlatformUserDetailPage() { - {/* All Roles (including tenant) */} - {user.roles && user.roles.filter((r) => r.role?.scope !== "PLATFORM").length > 0 && ( - - - Tenant Roles - Roles assigned within specific organizations - - + {/* Tenant Roles */} + + +
+
+ Tenant Roles + Roles assigned within specific organizations +
+ + + + + + + Assign Tenant Role + + Choose an organization and role to assign to {user.email}. + + +
+
+ + +
+
+ + + {selectedTenantId && !isLoadingTenantRoles && availableTenantRoles.length === 0 && ( +

+ No additional roles available in this organization. +

+ )} +
+
+ + + +
+
+
+
+ + {userTenantRoles.length === 0 ? ( +

+ No tenant roles assigned. +

+ ) : (
- {user.roles - .filter((r) => r.role?.scope !== "PLATFORM") - .map((ra, idx: number) => ( -
-
- -
-

{ra.role.name}

-

{ra.role.description}

-
+ {userTenantRoles.map((ra, idx) => ( +
+
+ +
+

{ra.role.name}

+

+ {ra.role.description} +

- - Tenant: {ra.tenant_id?.slice(0, 8)}... +
+
+ + {tenantNameById[ra.tenant_id ?? ""] || ra.tenant_id?.slice(0, 8)} +
- ))} +
+ ))}
- - - )} + )} + +
+ + !open && setRemoveRoleTarget(null)}> + + + Remove Role + + {removeRoleTarget?.scope === "platform" ? ( + <> + Are you sure you want to remove the platform role{" "} + {removeRoleTarget.roleName} from{" "} + {user.email}? + + ) : removeRoleTarget?.scope === "tenant" ? ( + <> + Are you sure you want to remove{" "} + {removeRoleTarget.roleName} from{" "} + {user.email} in{" "} + {removeRoleTarget.tenantName}? + + ) : null} + + + + + + + +
); } diff --git a/src/app/(dashboard)/platform/users/page.tsx b/src/app/(dashboard)/platform/users/page.tsx index 740a93b..e38aa9f 100644 --- a/src/app/(dashboard)/platform/users/page.tsx +++ b/src/app/(dashboard)/platform/users/page.tsx @@ -9,10 +9,8 @@ import { MoreVertical, ShieldCheck, Loader2, - Filter, UserCheck, UserX, - Shield, Eye, Trash2 } from "lucide-react"; @@ -42,11 +40,13 @@ import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { useState } from "react"; import { useRouter } from "next/navigation"; +import { ConfirmDialog } from "@/components/confirm-dialog"; export default function PlatformUsersPage() { const router = useRouter(); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; email: string } | null>(null); // 1. Fetch Users const { data: users, isLoading } = useQuery({ @@ -110,9 +110,6 @@ export default function PlatformUsersPage() { onChange={(e) => setSearchTerm(e.target.value)} />
-
@@ -183,9 +180,7 @@ export default function PlatformUsersPage() { > View Details - - Global Roles - + {u.status === "ACTIVE" ? ( { - if (confirm(`Delete user ${u.email}? This cannot be undone.`)) { - deleteMutation.mutate(u.id); - } - }} + onClick={() => + setDeleteTarget({ id: u.id, email: u.email }) + } > Delete User @@ -228,6 +221,25 @@ export default function PlatformUsersPage() { )} + + !open && setDeleteTarget(null)} + title="Delete user?" + description={ + deleteTarget + ? `${deleteTarget.email} will be permanently deleted. This cannot be undone.` + : undefined + } + confirmLabel="Delete" + loading={deleteMutation.isPending} + onConfirm={() => { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget.id); + setDeleteTarget(null); + } + }} + />
); } diff --git a/src/app/(dashboard)/tenant/audit/page.tsx b/src/app/(dashboard)/tenant/audit/page.tsx index b84b47c..86c1772 100644 --- a/src/app/(dashboard)/tenant/audit/page.tsx +++ b/src/app/(dashboard)/tenant/audit/page.tsx @@ -6,7 +6,6 @@ import { AuditLogEntry } from "@/lib/types"; import { useAuthStore } from "@/stores/auth-store"; import { Search, - Filter, Clock, Activity, ArrowRight, @@ -27,18 +26,22 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { normalizeAuditLogs } from "@/lib/audit"; +import { AuditLogDetailsDialog } from "@/components/audit/audit-log-details-dialog"; export default function TenantAuditPage() { const { activeTenantId } = useAuthStore(); const [searchTerm, setSearchTerm] = useState(""); + const [selectedLog, setSelectedLog] = useState(null); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); // Fetch Tenant Audit Logs const { data: logs, isLoading } = useQuery({ queryKey: ["tenantAudit", activeTenantId], queryFn: async () => { const { data } = await apiClient.get(`/tenants/${activeTenantId}/audit-logs`); - return data; + return normalizeAuditLogs(data); }, enabled: !!activeTenantId, }); @@ -50,13 +53,27 @@ export default function TenantAuditPage() { return "text-muted-foreground border-muted bg-muted/5"; }; + const filteredLogs = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) return logs ?? []; + + return (logs ?? []).filter((log) => + log.action?.toLowerCase().includes(term) || + log.actor_id?.toLowerCase().includes(term) || + log.ip_address?.toLowerCase().includes(term) || + log.resource?.toLowerCase().includes(term) || + log.resource_type?.toLowerCase().includes(term) || + log.resource_id?.toLowerCase().includes(term) || + log.user_agent?.toLowerCase().includes(term) + ); + }, [logs, searchTerm]); + if (!activeTenantId) return
Select an organization.
; - const filteredLogs = logs?.filter((log) => - log.action?.toLowerCase().includes(searchTerm.toLowerCase()) || - log.actor_id?.toLowerCase().includes(searchTerm.toLowerCase()) || - log.ip_address?.toLowerCase().includes(searchTerm.toLowerCase()) - ); + const openDetails = (log: AuditLogEntry) => { + setSelectedLog(log); + setIsDetailsOpen(true); + }; return (
@@ -82,9 +99,6 @@ export default function TenantAuditPage() { onChange={(e) => setSearchTerm(e.target.value)} />
-
@@ -111,9 +125,9 @@ export default function TenantAuditPage() { {log.action.replace(/_/g, ' ')} - {log.resource_type && ( + {(log.resource ?? log.resource_type) && (

- on {log.resource_type} + on {log.resource ?? log.resource_type}

)}
@@ -137,16 +151,24 @@ export default function TenantAuditPage() {
- ))} - {(!filteredLogs || filteredLogs.length === 0) && ( + {filteredLogs.length === 0 && ( - No audit logs found for this organization. + {searchTerm.trim() + ? "No audit logs match your search." + : "No audit logs found for this organization."} )} @@ -154,6 +176,16 @@ export default function TenantAuditPage() { )} + + { + setIsDetailsOpen(open); + if (!open) setSelectedLog(null); + }} + getActionColor={getActionColor} + />
); } diff --git a/src/app/(dashboard)/tenant/communications/page.tsx b/src/app/(dashboard)/tenant/communications/page.tsx index 5cf27b5..c34f6e8 100644 --- a/src/app/(dashboard)/tenant/communications/page.tsx +++ b/src/app/(dashboard)/tenant/communications/page.tsx @@ -1,427 +1,535 @@ "use client"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { apiClient } from "@/lib/api-client"; -import { getApiErrorMessage } from "@/lib/errors"; -import { EmailConfig, EmailConfigForm, SmsConfigForm, SmsConfigResponse } from "@/lib/types"; -import { useAuthStore } from "@/stores/auth-store"; -import { - Mail, - Smartphone, - Send, - CheckCircle2, - Loader2, - Save, - Trash2, - AlertCircle -} from "lucide-react"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Mail, Plus, Smartphone, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; -import { toast } from "sonner"; -import { useState } from "react"; - -const DEFAULT_EMAIL_FORM: EmailConfigForm = { - provider: "smtp", - from_email: "", - api_key: "", - is_active: true, -}; +import { Label } from "@/components/ui/label"; +import { apiClient } from "@/lib/api-client"; +import { getApiErrorMessage } from "@/lib/errors"; +import { + EmailConfigForm, + EmailConfigListResponse, + SmsConfigForm, + SmsConfigListResponse, +} from "@/lib/types"; +import { useAuthStore } from "@/stores/auth-store"; +import { ConfirmDialog } from "@/components/confirm-dialog"; -const DEFAULT_SMS_FORM: SmsConfigForm = { - provider: "twilio", - from_number: "", - api_key: "", - is_active: true, +const PROVIDER_LABELS: Record = { + sendgrid: "SendGrid", + ses: "AWS SES", + twilio: "Twilio", + android_gateway: "Android Gateway", }; -function buildEmailForm(config?: EmailConfig): EmailConfigForm { - if (config && !config.platform_provider) { - return { - provider: config.provider, - from_email: config.from_email, - api_key: "", - is_active: config.is_active, - }; - } - return DEFAULT_EMAIL_FORM; +function providerLabel(value: string) { + return PROVIDER_LABELS[value] ?? value; } -function buildSmsForm(config?: SmsConfigResponse): SmsConfigForm { - if (config && !config.platform_provider) { - return { - provider: config.provider, - from_number: config.from_number, - api_key: "", - is_active: config.is_active, - }; - } - return DEFAULT_SMS_FORM; +function ConfigRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); } -function EmailConfigSection({ - emailConfig, +function EmailSection({ activeTenantId, + data, + isLoading, }: { - emailConfig?: EmailConfig; activeTenantId: string; + data?: EmailConfigListResponse; + isLoading: boolean; }) { const queryClient = useQueryClient(); - const [emailForm, setEmailForm] = useState(() => buildEmailForm(emailConfig)); - const [testEmail, setTestEmail] = useState(""); + const [open, setOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [form, setForm] = useState({ + provider: "ses", + from_email: "", + api_key: "", + set_active: true, + }); - const isUsingPlatformEmail = !!emailConfig?.platform_provider; + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["tenantEmailConfig", activeTenantId] }); - const saveEmailMutation = useMutation({ - mutationFn: async (payload: EmailConfigForm) => { - if (emailConfig?.platform_provider) { - await apiClient.post(`/tenants/${activeTenantId}/email-config`, payload); - } else { - await apiClient.put(`/tenants/${activeTenantId}/email-config`, payload); - } + const createMutation = useMutation({ + mutationFn: async () => { + await apiClient.post(`/tenants/${activeTenantId}/email-config`, form); }, onSuccess: () => { - toast.success("Email configuration saved"); - queryClient.invalidateQueries({ queryKey: ["tenantEmailConfig", activeTenantId] }); + toast.success("Email configuration added"); + setOpen(false); + setForm({ provider: "ses", from_email: "", api_key: "", set_active: true }); + invalidate(); }, onError: (error: unknown) => { - toast.error(getApiErrorMessage(error, "Failed to save email config")); + toast.error(getApiErrorMessage(error, "Failed to add email config")); }, }); - const testEmailMutation = useMutation({ - mutationFn: async (toEmail: string) => { - const { data } = await apiClient.post<{ success: boolean; error?: string }>( - `/tenants/${activeTenantId}/email-config/test`, - { to_email: toEmail } - ); - return data; + const activateMutation = useMutation({ + mutationFn: async (configId: string) => { + await apiClient.put(`/tenants/${activeTenantId}/email-config/${configId}`, { + set_active: true, + }); }, - onSuccess: (data) => { - if (data.success) { - toast.success("Test email sent successfully!"); - } else { - toast.error(`Failed to send test email: ${data.error}`); - } + onSuccess: () => { + toast.success("Active email configuration updated"); + invalidate(); }, onError: (error: unknown) => { - toast.error(getApiErrorMessage(error, "Test email request failed")); + toast.error(getApiErrorMessage(error, "Failed to activate configuration")); }, }); + const deleteMutation = useMutation({ + mutationFn: async (configId: string) => { + await apiClient.delete(`/tenants/${activeTenantId}/email-config/${configId}`); + }, + onSuccess: () => { + toast.success("Email configuration removed"); + invalidate(); + }, + onError: (error: unknown) => { + toast.error(getApiErrorMessage(error, "Failed to delete configuration")); + }, + }); + + const providers = data?.available_providers ?? ["sendgrid", "ses"]; + return ( - - -
-
- + + +
+
+ - Email Provider + Email - Emails are sent for magic links, password resets, and invitations. + Magic links, password resets, and invitations.
- {isUsingPlatformEmail ? ( - - Using Platform Default - - ) : ( - - Custom Config Active - - )} +
- - {isUsingPlatformEmail && emailConfig && ( -
-

Default Provider Active

-

- Currently using {emailConfig.platform_provider} from {emailConfig.platform_from_email}. - Configure your own provider below to use a custom "From" address and dedicated reputation. -

+ + {isLoading ? ( +
+
+ ) : ( + <> + {data?.using_platform_default ? ( +
+ Using platform default:{" "} + {providerLabel(data.platform_provider ?? "")} + {" · "} + {data.platform_from_email} +
+ ) : null} + {data?.items.length ? ( + data.items.map((item) => ( +
+
+
+ {providerLabel(item.provider)} +
+ + {item.is_active ? "Active" : "Inactive"} + +
+ + +
+ {!item.is_active ? ( + + ) : null} + +
+
+ )) + ) : ( + !data?.using_platform_default && ( +

+ No configurations yet. +

+ ) + )} + )} +
-
-
- - -
-
- - setEmailForm({ ...emailForm, from_email: e.target.value })} - /> + + + + Add email configuration + +
+
+ + +
+
+ + + setForm({ ...form, from_email: e.target.value }) + } + /> +
+
+ + setForm({ ...form, api_key: e.target.value })} + /> +
-
- -
- - setEmailForm({ ...emailForm, api_key: e.target.value })} - /> -
+ + + + + -
-
-

Activate this configuration

-

When enabled, platform defaults will be ignored.

-
- setEmailForm({ ...emailForm, is_active: val })} - /> -
- - -
- setTestEmail(e.target.value)} - /> - -
- -
+ !open && setDeleteTargetId(null)} + title="Delete this configuration?" + description="This action cannot be undone." + confirmLabel="Delete" + loading={deleteMutation.isPending} + onConfirm={() => { + if (deleteTargetId) { + deleteMutation.mutate(deleteTargetId); + setDeleteTargetId(null); + } + }} + /> ); } -function SmsConfigSection({ - smsConfig, +function SmsSection({ activeTenantId, + data, + isLoading, }: { - smsConfig?: SmsConfigResponse; activeTenantId: string; + data?: SmsConfigListResponse; + isLoading: boolean; }) { const queryClient = useQueryClient(); - const [smsForm, setSmsForm] = useState(() => buildSmsForm(smsConfig)); - const [testPhone, setTestPhone] = useState(""); + const [open, setOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [form, setForm] = useState({ + provider: "android_gateway", + from_number: "gateway", + api_key: "", + account_sid: "", + set_active: true, + }); - const isUsingPlatformSMS = !!smsConfig?.platform_provider; + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["tenantSmsConfig", activeTenantId] }); - const saveSMSMutation = useMutation({ - mutationFn: async (payload: SmsConfigForm) => { - if (smsConfig?.platform_provider) { - await apiClient.post(`/tenants/${activeTenantId}/sms-config`, payload); - } else { - await apiClient.put(`/tenants/${activeTenantId}/sms-config`, payload); - } + const createMutation = useMutation({ + mutationFn: async () => { + await apiClient.post(`/tenants/${activeTenantId}/sms-config`, { + ...form, + account_sid: form.account_sid || null, + }); }, onSuccess: () => { - toast.success("SMS configuration saved"); - queryClient.invalidateQueries({ queryKey: ["tenantSmsConfig", activeTenantId] }); + toast.success("SMS configuration added"); + setOpen(false); + setForm({ + provider: "android_gateway", + from_number: "gateway", + api_key: "", + account_sid: "", + set_active: true, + }); + invalidate(); }, onError: (error: unknown) => { - toast.error(getApiErrorMessage(error, "Failed to save SMS config")); + toast.error(getApiErrorMessage(error, "Failed to add SMS config")); }, }); - const deleteSMSMutation = useMutation({ - mutationFn: async () => { - await apiClient.delete(`/tenants/${activeTenantId}/sms-config`); + const activateMutation = useMutation({ + mutationFn: async (configId: string) => { + await apiClient.put(`/tenants/${activeTenantId}/sms-config/${configId}`, { + set_active: true, + }); }, onSuccess: () => { - toast.success("SMS config removed, reverted to platform default"); - setSmsForm(DEFAULT_SMS_FORM); - queryClient.invalidateQueries({ queryKey: ["tenantSmsConfig", activeTenantId] }); + toast.success("Active SMS configuration updated"); + invalidate(); }, onError: (error: unknown) => { - toast.error(getApiErrorMessage(error, "Failed to remove SMS config")); + toast.error(getApiErrorMessage(error, "Failed to activate configuration")); }, }); - const testSMSMutation = useMutation({ - mutationFn: async (toNumber: string) => { - const { data } = await apiClient.post<{ success: boolean; error?: string }>( - `/tenants/${activeTenantId}/sms-config/test`, - { to_number: toNumber } - ); - return data; + const deleteMutation = useMutation({ + mutationFn: async (configId: string) => { + await apiClient.delete(`/tenants/${activeTenantId}/sms-config/${configId}`); }, - onSuccess: (data) => { - if (data.success) { - toast.success("Test SMS sent successfully!"); - } else { - toast.error(`Failed to send test SMS: ${data.error}`); - } + onSuccess: () => { + toast.success("SMS configuration removed"); + invalidate(); }, onError: (error: unknown) => { - toast.error(getApiErrorMessage(error, "Test SMS request failed")); + toast.error(getApiErrorMessage(error, "Failed to delete configuration")); }, }); + const providers = data?.available_providers ?? ["twilio", "android_gateway"]; + return ( - - -
-
- + + +
+
+ - SMS Gateway + SMS - - Set up Twilio or MessageBird for SMS-based MFA and notifications. - + SMS OTP and phone verification.
- {isUsingPlatformSMS ? ( - - Using Platform Default - - ) : smsConfig ? ( - - Custom Config Active - - ) : ( - - Not Configured - - )} +
- - {isUsingPlatformSMS && smsConfig && ( -
-

Platform Default Active

-

- Currently using {smsConfig.platform_provider} from {smsConfig.platform_from_number}. - Configure your own provider below to use a custom sender number. -

+ + {isLoading ? ( +
+
+ ) : ( + <> + {data?.using_platform_default ? ( +
+ Using platform default:{" "} + {providerLabel(data.platform_provider ?? "")} + {" · "} + {data.platform_from_number} +
+ ) : null} + {data?.items.length ? ( + data.items.map((item) => ( +
+
+
+ {providerLabel(item.provider)} +
+ + {item.is_active ? "Active" : "Inactive"} + +
+ + {item.account_sid ? ( + + ) : null} + +
+ {!item.is_active ? ( + + ) : null} + +
+
+ )) + ) : ( + !data?.using_platform_default && ( +

+ No configurations yet. +

+ ) + )} + )} +
-
-
- - -
-
- - setSmsForm({ ...smsForm, from_number: e.target.value })} - /> -
-
- -
- - setSmsForm({ ...smsForm, api_key: e.target.value })} - /> -
- -
-
-

Activate this configuration

-

When enabled, platform SMS defaults will be ignored.

+ + + + Add SMS configuration + +
+
+ + +
+
+ + + setForm({ ...form, from_number: e.target.value }) + } + /> +
+ {form.provider === "android_gateway" ? ( +
+ + + setForm({ ...form, account_sid: e.target.value }) + } + placeholder="https://api.sms-gate.app/3rdparty/v1" + /> +
+ ) : ( +
+ + + setForm({ ...form, account_sid: e.target.value }) + } + /> +
+ )} +
+ + setForm({ ...form, api_key: e.target.value })} + /> +
- setSmsForm({ ...smsForm, is_active: val })} - /> -
- - -
- setTestPhone(e.target.value)} - /> - -
-
- {!isUsingPlatformSMS && smsConfig && ( + - )} - -
-
+ + + + + !open && setDeleteTargetId(null)} + title="Delete this configuration?" + description="This action cannot be undone." + confirmLabel="Delete" + loading={deleteMutation.isPending} + onConfirm={() => { + if (deleteTargetId) { + deleteMutation.mutate(deleteTargetId); + setDeleteTargetId(null); + } + }} + /> ); } @@ -429,50 +537,47 @@ function SmsConfigSection({ export default function TenantCommunicationsPage() { const { activeTenantId } = useAuthStore(); - const { data: emailConfig, isLoading: isLoadingEmail } = useQuery({ + const { data: emailData, isLoading: isLoadingEmail } = useQuery({ queryKey: ["tenantEmailConfig", activeTenantId], queryFn: async () => { - const { data } = await apiClient.get(`/tenants/${activeTenantId}/email-config`); + const { data } = await apiClient.get( + `/tenants/${activeTenantId}/email-config` + ); return data; }, enabled: !!activeTenantId, }); - const { data: smsConfig, isLoading: isLoadingSms } = useQuery({ + const { data: smsData, isLoading: isLoadingSms } = useQuery({ queryKey: ["tenantSmsConfig", activeTenantId], queryFn: async () => { - const { data } = await apiClient.get(`/tenants/${activeTenantId}/sms-config`); + const { data } = await apiClient.get( + `/tenants/${activeTenantId}/sms-config` + ); return data; }, enabled: !!activeTenantId, }); - if (!activeTenantId) return
Select an organization.
; - if (isLoadingEmail || isLoadingSms) { - return
; + if (!activeTenantId) { + return
Select an organization.
; } return ( -
+

Communications

- Configure custom email and SMS providers for your organization's notifications. + Add multiple email and SMS providers. Only the active configuration is used.

-
- - -
+ +
); } diff --git a/src/app/(dashboard)/tenant/roles/page.tsx b/src/app/(dashboard)/tenant/roles/page.tsx index 8baf701..f501168 100644 --- a/src/app/(dashboard)/tenant/roles/page.tsx +++ b/src/app/(dashboard)/tenant/roles/page.tsx @@ -1,16 +1,19 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; +import { getApiErrorMessage } from "@/lib/errors"; import { Permission, Role } from "@/lib/types"; -import { useAuthStore } from "@/stores/auth-store"; +import { useActiveTenant } from "@/hooks/use-active-tenant"; import { - ShieldCheck, Plus, + Trash2, + ShieldCheck, Loader2, - ChevronRight, - Info, - Shield + Edit, + AlertCircle, } from "lucide-react"; import { @@ -21,100 +24,358 @@ import { CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { useState, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +interface RolePayload { + name: string; + description: string; + level: number; + permissions: string[]; +} export default function TenantRolesPage() { - const { activeTenantId } = useAuthStore(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { activeTenantId, isReady, isPlatformTenant, isLoading: isLoadingTenant } = useActiveTenant(); + + useEffect(() => { + if (isReady && isPlatformTenant) { + router.replace("/platform/roles"); + } + }, [isReady, isPlatformTenant, router]); - const { data: roles, isLoading } = useQuery({ + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deletingRole, setDeletingRole] = useState(null); + const [formData, setFormData] = useState({ + name: "", + description: "", + level: 0, + permissions: [] as string[], + }); + + const { data: roles = [], isLoading: isLoadingRoles } = useQuery({ queryKey: ["tenantRoles", activeTenantId], queryFn: async () => { const { data } = await apiClient.get(`/tenants/${activeTenantId}/roles`); return data; }, - enabled: !!activeTenantId, + enabled: isReady && !isPlatformTenant, + }); + + const { data: permissions = [], isLoading: isLoadingPerms } = useQuery({ + queryKey: ["tenantRolePermissions", activeTenantId], + queryFn: async () => { + const { data } = await apiClient.get( + `/tenants/${activeTenantId}/roles/permissions` + ); + return data; + }, + enabled: isReady && !isPlatformTenant, + }); + + const availablePerms = useMemo(() => permissions, [permissions]); + + const handleOpenCreate = () => { + setEditingRole(null); + setFormData({ name: "", description: "", level: 0, permissions: [] }); + setIsDialogOpen(true); + }; + + const handleOpenEdit = (role: Role) => { + setEditingRole(role); + setFormData({ + name: role.name, + description: role.description || "", + level: role.level, + permissions: role.permission_ids || [], + }); + setIsDialogOpen(true); + }; + + const saveMutation = useMutation({ + mutationFn: async (payload: RolePayload) => { + if (editingRole) { + const { data } = await apiClient.put( + `/tenants/${activeTenantId}/roles/${editingRole.id}`, + payload + ); + return data; + } + const { data } = await apiClient.post(`/tenants/${activeTenantId}/roles`, payload); + return data; + }, + onSuccess: () => { + toast.success(editingRole ? "Role updated!" : "Role created!"); + queryClient.invalidateQueries({ queryKey: ["tenantRoles", activeTenantId] }); + setIsDialogOpen(false); + }, + onError: (err: unknown) => { + toast.error(getApiErrorMessage(err, "Failed to save role.")); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/tenants/${activeTenantId}/roles/${id}`); + }, + onSuccess: () => { + toast.success("Role deleted!"); + setIsDeleting(false); + setDeletingRole(null); + queryClient.invalidateQueries({ queryKey: ["tenantRoles", activeTenantId] }); + }, + onError: (err: unknown) => { + toast.error(getApiErrorMessage(err, "Failed to delete role.")); + }, }); - if (!activeTenantId) return
Select an organization first.
; + const handleSubmit = () => { + saveMutation.mutate({ + name: formData.name, + description: formData.description, + level: parseInt(formData.level.toString(), 10), + permissions: formData.permissions, + }); + }; + + const togglePermission = (id: string, checked: boolean) => { + setFormData((prev) => { + if (checked) { + return { ...prev, permissions: [...prev.permissions, id] }; + } + return { ...prev, permissions: prev.permissions.filter((p) => p !== id) }; + }); + }; + + if (isLoadingTenant || isPlatformTenant) { + return ( +
+ +
+ ); + } + + if (!isReady) { + return
Select an organization first.
; + } return (
-
-
-

Organization Roles

-

- Manage permissions for members within this organization. -

+ +
+
+

Organization Roles

+

+ Manage roles and permissions for this organization. +

+
+ + +
- -
-
- {isLoading ? ( -
+ {(isLoadingRoles || isLoadingPerms) ? ( +
) : ( - roles?.map((role) => ( - - -
- - {role.name} - - -
- {role.name} - - {role.description || `Standard permissions for ${role.name}s in this tenant.`} - -
- -
+
+ {roles.map((role) => ( + + +
+ + {role.template_role_id ? "Inherited" : "Custom"} + + + Lvl {role.level} + +
+ {role.name} + + {role.description || "No description provided."} + +
+

- Scopes + Permissions

- {role.permissions?.length > 0 ? ( - role.permissions.slice(0, 4).map((p: Permission) => ( - - {p.name} - - )) - ) : ( - No specific scopes defined. + {!role.permissions?.length && ( + None assigned )} - {role.permissions?.length > 4 && ( + {role.permissions?.slice(0, 4).map((p) => ( + + {p.name} + + ))} + {role.permissions && role.permissions.length > 4 && ( +{role.permissions.length - 4} more )}
+
+
+ + {!role.is_protected_tenant_role && ( + + )}
- -
- ID: {role.id.slice(0, 8)} - + + ))} + {roles.length === 0 && ( +
+ +

No roles found for this organization.

- - )) + )} +
)} -
-
- -
-

Role Propagation

-

- Permissions granted via these roles apply only to resources owned by this organization. - Users can have different roles across different organizations they belong to. -

-
-
+ + + {editingRole ? "Edit Role" : "Create Role"} + + {editingRole + ? "Update this organization role and its permissions." + : "Create a custom role for this organization."} + + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. BILLING_MANAGER" + className="uppercase" + /> +
+ +
+ +