Feat: RBAC, Keys Extension, Invites
feat: org members page — invite users, cancel invites, change roles feat: show pending invitations banner on profile page feat: invite accept flow for existing users (no password needed) feat: departments page updates feat: SSH keys page — dept cert policy UI (expiry + extensions) feat: wire up auth pages to real API (register, verify, reset, OIDC) feat: CLI auth bridge — login page handles CLI token flow feat: admin users — suspend/unsuspend, role badges, role filter feat: add admin OAuth providers management page feat: activity page — org-wide audit log view for admins feat: add my memberships page chore: add isOrgAdmin/isOrgMember to AuthContext, restrict sidebar chore: update app routing and shared layout
This commit is contained in:
@@ -8,6 +8,11 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
Shield,
|
||||
Ban,
|
||||
UserCheck,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -36,16 +41,48 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, User as ApiUser, SSHKey, ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return "—";
|
||||
return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
const r = (role || "").toLowerCase();
|
||||
if (r === "owner") {
|
||||
return (
|
||||
<Badge className="bg-purple-500/10 text-purple-600 border-purple-200 text-xs">
|
||||
<ShieldCheck className="w-3 h-3 mr-1" />Owner
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (r === "admin") {
|
||||
return (
|
||||
<Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">
|
||||
<Shield className="w-3 h-3 mr-1" />Admin
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Member
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
// User list
|
||||
const [users, setUsers] = useState<ApiUser[]>([]);
|
||||
@@ -55,6 +92,7 @@ export default function AdminUsersPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("all");
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
@@ -67,6 +105,9 @@ export default function AdminUsersPage() {
|
||||
const [userSshKeys, setUserSshKeys] = useState<SSHKey[]>([]);
|
||||
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||
|
||||
// Role update
|
||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
||||
|
||||
// Admin add SSH key dialog
|
||||
const [showAddKey, setShowAddKey] = useState(false);
|
||||
const [addKeyPublicKey, setAddKeyPublicKey] = useState("");
|
||||
@@ -74,6 +115,10 @@ export default function AdminUsersPage() {
|
||||
const [isAddingKey, setIsAddingKey] = useState(false);
|
||||
const [addKeyError, setAddKeyError] = useState<string | null>(null);
|
||||
|
||||
// Suspend / unsuspend
|
||||
const [isSuspending, setIsSuspending] = useState(false);
|
||||
const [showSuspendConfirm, setShowSuspendConfirm] = useState(false);
|
||||
|
||||
// ── Fetch users ─────────────────────────────────────────────────────────────
|
||||
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||
setIsLoading(true);
|
||||
@@ -123,6 +168,32 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Update role ──────────────────────────────────────────────────────────────
|
||||
const handleRoleChange = async (newRole: string) => {
|
||||
if (!selectedUser || !selectedUser.org_id) return;
|
||||
setIsUpdatingRole(true);
|
||||
try {
|
||||
await api.admin.updateUserRole(selectedUser.org_id, selectedUser.id, newRole.toUpperCase());
|
||||
const updated = { ...selectedUser, org_role: newRole };
|
||||
setSelectedUser(updated);
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === selectedUser.id ? { ...u, org_role: newRole } : u))
|
||||
);
|
||||
toast({
|
||||
title: "Role updated",
|
||||
description: `${selectedUser.full_name || selectedUser.email} is now a ${newRole}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update role",
|
||||
description: err instanceof ApiError ? err.message : "Something went wrong",
|
||||
});
|
||||
} finally {
|
||||
setIsUpdatingRole(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Admin add SSH key ────────────────────────────────────────────────────────
|
||||
const handleAddKey = async () => {
|
||||
if (!selectedUser) return;
|
||||
@@ -146,6 +217,47 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Suspend / Unsuspend user ─────────────────────────────────────────────────
|
||||
const handleSuspend = async () => {
|
||||
if (!selectedUser) return;
|
||||
setIsSuspending(true);
|
||||
try {
|
||||
const data = await api.admin.suspendUser(selectedUser.id);
|
||||
const updated = { ...selectedUser, status: data.user.status };
|
||||
setSelectedUser(updated);
|
||||
setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, status: data.user.status } : u));
|
||||
setShowSuspendConfirm(false);
|
||||
toast({ title: "User suspended", description: `${selectedUser.full_name || selectedUser.email} has been suspended.` });
|
||||
} catch (err) {
|
||||
toast({ variant: "destructive", title: "Failed to suspend user", description: err instanceof ApiError ? err.message : "Something went wrong" });
|
||||
} finally {
|
||||
setIsSuspending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsuspend = async () => {
|
||||
if (!selectedUser) return;
|
||||
setIsSuspending(true);
|
||||
try {
|
||||
const data = await api.admin.unsuspendUser(selectedUser.id);
|
||||
const updated = { ...selectedUser, status: data.user.status };
|
||||
setSelectedUser(updated);
|
||||
setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, status: data.user.status } : u));
|
||||
toast({ title: "User unsuspended", description: `${selectedUser.full_name || selectedUser.email} is now active.` });
|
||||
} catch (err) {
|
||||
toast({ variant: "destructive", title: "Failed to unsuspend user", description: err instanceof ApiError ? err.message : "Something went wrong" });
|
||||
} finally {
|
||||
setIsSuspending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter by role client-side
|
||||
const filteredUsers = users.filter((u) => {
|
||||
if (roleFilter === "all") return true;
|
||||
const r = (u.org_role || "member").toLowerCase();
|
||||
return r === roleFilter;
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="page-container">
|
||||
@@ -156,15 +268,28 @@ export default function AdminUsersPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{/* Search + filter bar */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All roles</SelectItem>
|
||||
<SelectItem value="owner">Owner</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -174,21 +299,21 @@ export default function AdminUsersPage() {
|
||||
Users
|
||||
{!isLoading && <Badge variant="secondary" className="ml-1">{total}</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription>Click a user to view details and manage their SSH keys</CardDescription>
|
||||
<CardDescription>Click a user to view details and manage their role or SSH keys</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<User className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||||
<p className="text-sm">{debouncedSearch ? "No users match your search" : "No users found"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{users.map((user) => (
|
||||
{filteredUsers.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left"
|
||||
@@ -204,7 +329,13 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{(user as ApiUser & { activated?: boolean }).activated === false && (
|
||||
<RoleBadge role={user.org_role || "member"} />
|
||||
{user.status === "suspended" && (
|
||||
<Badge variant="outline" className="text-xs text-red-600 border-red-300 bg-red-50">
|
||||
<Ban className="w-3 h-3 mr-1" />Suspended
|
||||
</Badge>
|
||||
)}
|
||||
{user.activated === false && (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
||||
Not activated
|
||||
</Badge>
|
||||
@@ -261,19 +392,98 @@ export default function AdminUsersPage() {
|
||||
{/* Basic info */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{selectedUser.status === "suspended" ? (
|
||||
<><Ban className="w-4 h-4 text-red-500" /><span className="text-red-600 font-medium">Suspended</span></>
|
||||
) : (
|
||||
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-green-600">Active</span></>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Joined</span>
|
||||
<span>{formatDate(selectedUser.created_at)}</span>
|
||||
<span className="text-muted-foreground">Activated</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{(selectedUser as ApiUser & { activated?: boolean }).activated === false ? (
|
||||
{selectedUser.activated === false ? (
|
||||
<><XCircle className="w-4 h-4 text-amber-500" /> No</>
|
||||
) : (
|
||||
<><CheckCircle className="w-4 h-4 text-green-500" /> Yes</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Last login</span>
|
||||
<span>{formatDate(selectedUser.last_login_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suspend / Unsuspend — only for other users */}
|
||||
{selectedUser.id !== currentUser?.id && (
|
||||
<div className="mb-6 p-4 border rounded-lg space-y-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Ban className="w-4 h-4" />
|
||||
Account Access
|
||||
</h3>
|
||||
{selectedUser.status === "suspended" ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">This account is suspended. The user cannot log in or request certificates.</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUnsuspend}
|
||||
disabled={isSuspending}
|
||||
className="text-green-600 border-green-300 hover:bg-green-50"
|
||||
>
|
||||
{isSuspending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <UserCheck className="w-4 h-4 mr-2" />}
|
||||
Restore account
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Suspending blocks this user from logging in and requesting SSH certificates.</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowSuspendConfirm(true)}
|
||||
disabled={isSuspending}
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
<Ban className="w-4 h-4 mr-2" />
|
||||
Suspend account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role management — only if not viewing yourself and user has org_id */}
|
||||
{selectedUser.org_id && selectedUser.id !== currentUser?.id && (
|
||||
<div className="mb-6 p-4 border rounded-lg space-y-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
Organization Role
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={(selectedUser.org_role || "member").toLowerCase()}
|
||||
onValueChange={handleRoleChange}
|
||||
disabled={isUpdatingRole || (selectedUser.org_role || "").toLowerCase() === "owner"}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="owner">Owner</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isUpdatingRole && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
{(selectedUser.org_role || "").toLowerCase() === "owner" && (
|
||||
<p className="text-xs text-muted-foreground">Owner role cannot be changed here. Transfer ownership from the Members page.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Keys section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -371,6 +581,34 @@ export default function AdminUsersPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Suspend confirmation dialog ───────────────────────────────────────── */}
|
||||
<Dialog open={showSuspendConfirm} onOpenChange={setShowSuspendConfirm}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Suspend account?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{selectedUser?.full_name || selectedUser?.email}</strong> will be blocked from logging in and requesting SSH certificates. You can restore their access at any time.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSuspendConfirm(false)} disabled={isSuspending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleSuspend}
|
||||
disabled={isSuspending}
|
||||
>
|
||||
{isSuspending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Suspend
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Loader2, Settings, Trash2, Plus, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface OAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
is_configured: boolean;
|
||||
is_enabled: boolean;
|
||||
client_id: string | null;
|
||||
}
|
||||
|
||||
const PROVIDER_LOGOS: Record<string, string> = {
|
||||
google: "https://www.google.com/favicon.ico",
|
||||
github: "https://github.com/favicon.ico",
|
||||
microsoft: "https://www.microsoft.com/favicon.ico",
|
||||
};
|
||||
|
||||
const PROVIDER_HELP: Record<string, { docsUrl: string; callbackNote: string }> = {
|
||||
google: {
|
||||
docsUrl: "https://console.cloud.google.com/apis/credentials",
|
||||
callbackNote: "Authorized redirect URI: http://localhost:5000/api/v1/auth/external/google/callback",
|
||||
},
|
||||
github: {
|
||||
docsUrl: "https://github.com/settings/applications/new",
|
||||
callbackNote: "Authorization callback URL: http://localhost:5000/api/v1/auth/external/github/callback",
|
||||
},
|
||||
microsoft: {
|
||||
docsUrl: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps",
|
||||
callbackNote: "Redirect URI: http://localhost:5000/api/v1/auth/external/microsoft/callback",
|
||||
},
|
||||
};
|
||||
|
||||
export default function OAuthProvidersPage() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [configDialog, setConfigDialog] = useState<{ open: boolean; provider: OAuthProvider | null }>({
|
||||
open: false,
|
||||
provider: null,
|
||||
});
|
||||
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; provider: OAuthProvider | null }>({
|
||||
open: false,
|
||||
provider: null,
|
||||
});
|
||||
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [isEnabled, setIsEnabled] = useState(true);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "oauthProviders"],
|
||||
queryFn: () => api.admin.listOAuthProviders(),
|
||||
});
|
||||
|
||||
const configureMutation = useMutation({
|
||||
mutationFn: ({ provider, cid, cs, enabled }: { provider: string; cid: string; cs: string; enabled: boolean }) =>
|
||||
api.admin.configureOAuthProvider(provider, cid, cs, enabled),
|
||||
onSuccess: (_, { provider }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "oauthProviders"] });
|
||||
toast({ title: `${provider} configured`, description: "OAuth provider settings saved." });
|
||||
setConfigDialog({ open: false, provider: null });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Failed to save", description: err.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (provider: string) => api.admin.deleteOAuthProvider(provider),
|
||||
onSuccess: (_, provider) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "oauthProviders"] });
|
||||
toast({ title: `${provider} removed`, description: "OAuth provider configuration deleted." });
|
||||
setDeleteDialog({ open: false, provider: null });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Failed to delete", description: err.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const openConfig = (provider: OAuthProvider) => {
|
||||
setClientId(provider.client_id ?? "");
|
||||
setClientSecret("");
|
||||
setIsEnabled(provider.is_enabled);
|
||||
setShowSecret(false);
|
||||
setConfigDialog({ open: true, provider });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!configDialog.provider) return;
|
||||
configureMutation.mutate({
|
||||
provider: configDialog.provider.id,
|
||||
cid: clientId,
|
||||
cs: clientSecret,
|
||||
enabled: isEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
const providers: OAuthProvider[] = data?.providers ?? [];
|
||||
|
||||
return (
|
||||
<div className="container max-w-3xl py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">OAuth Providers</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure application-level OAuth provider credentials. Users can link their accounts via these providers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading providers…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{providers.map((p) => {
|
||||
const help = PROVIDER_HELP[p.id];
|
||||
return (
|
||||
<Card key={p.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={PROVIDER_LOGOS[p.id]}
|
||||
alt={p.name}
|
||||
className="h-5 w-5"
|
||||
onError={(e) => (e.currentTarget.style.display = "none")}
|
||||
/>
|
||||
<CardTitle className="text-base">{p.name}</CardTitle>
|
||||
{p.is_configured ? (
|
||||
<Badge variant={p.is_enabled ? "default" : "secondary"}>
|
||||
{p.is_enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-orange-600 border-orange-300">
|
||||
Not configured
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => openConfig(p)}>
|
||||
{p.is_configured ? (
|
||||
<><Settings className="h-3.5 w-3.5 mr-1" /> Edit</>
|
||||
) : (
|
||||
<><Plus className="h-3.5 w-3.5 mr-1" /> Configure</>
|
||||
)}
|
||||
</Button>
|
||||
{p.is_configured && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteDialog({ open: true, provider: p })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{p.is_configured && p.client_id && (
|
||||
<CardContent className="pt-0">
|
||||
<CardDescription className="font-mono text-xs">
|
||||
Client ID: {p.client_id.slice(0, 24)}…
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
)}
|
||||
{!p.is_configured && (
|
||||
<CardContent className="pt-0">
|
||||
<CardDescription className="text-xs">
|
||||
{help.callbackNote}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Configure Dialog */}
|
||||
<Dialog open={configDialog.open} onOpenChange={(o) => setConfigDialog((s) => ({ ...s, open: o }))}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{configDialog.provider?.is_configured ? "Edit" : "Configure"}{" "}
|
||||
{configDialog.provider?.name} OAuth
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{configDialog.provider && PROVIDER_HELP[configDialog.provider.id]?.callbackNote}
|
||||
{" "}
|
||||
<a
|
||||
href={configDialog.provider ? PROVIDER_HELP[configDialog.provider.id]?.docsUrl : "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
>
|
||||
Open provider console ↗
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client-id">Client ID</Label>
|
||||
<Input
|
||||
id="client-id"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder="Enter Client ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client-secret">
|
||||
Client Secret{" "}
|
||||
{configDialog.provider?.is_configured && (
|
||||
<span className="text-muted-foreground text-xs">(leave blank to keep existing)</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="client-secret"
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
placeholder={configDialog.provider?.is_configured ? "••••••••" : "Enter Client Secret"}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="is-enabled">Enable this provider</Label>
|
||||
<Switch id="is-enabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfigDialog({ open: false, provider: null })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!clientId || configureMutation.isPending}>
|
||||
{configureMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirm Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteDialog.open}
|
||||
onOpenChange={(o) => setDeleteDialog((s) => ({ ...s, open: o }))}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove {deleteDialog.provider?.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove the OAuth credentials for {deleteDialog.provider?.name}. Users will no longer be able
|
||||
to sign in or link accounts via this provider.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteDialog.provider && deleteMutation.mutate(deleteDialog.provider.id)}
|
||||
>
|
||||
{deleteMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Remove"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user