multica/apps/web/app/(dashboard)/settings/page.tsx
LinYushen 5c9c2f69fd
feat(auth): email verification login and personal access tokens
* feat(auth): add email verification login flow with 401 auto-redirect

Replace the old OAuth-based login with email verification codes:
- Backend: send-code / verify-code endpoints, verification_codes table (migration 009), rate limiting, Resend email service
- Frontend: two-step login UI (email → 6-digit OTP), auth store with sendCode/verifyCode
- SDK: ApiClient gains onUnauthorized callback; 401 responses auto-clear token and redirect to /login
- Fix login button staying disabled due to global isLoading state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): add brute-force protection, redirect loop guard, and expired code cleanup

- VerifyCode: increment attempts on wrong code, reject after 5 failed tries (migration 010)
- onUnauthorized: skip redirect if already on /login to prevent infinite loops
- SendCode: best-effort cleanup of expired verification codes older than 1 hour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add master verification code for non-production environments

Allow code "888888" to bypass email verification in non-production
environments to simplify development and testing workflows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add personal access tokens for CLI and API authentication

Add full-stack PAT support: users create tokens in Settings, CLI authenticates
via `multica auth login`. Server stores SHA-256 hashes only. Auth middleware
extended to accept both JWTs and PATs (distinguished by `mul_` prefix).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:32:30 +08:00

673 lines
23 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut, Key, Copy, Check } from "lucide-react";
import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@multica/types";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown }> = {
owner: { label: "Owner", icon: Crown },
admin: { label: "Admin", icon: Shield },
member: { label: "Member", icon: User },
};
function MemberRow({
member,
canManage,
canManageOwners,
isSelf,
busy,
onRoleChange,
onRemove,
}: {
member: MemberWithUser;
canManage: boolean;
canManageOwners: boolean;
isSelf: boolean;
busy: boolean;
onRoleChange: (role: MemberRole) => void;
onRemove: () => void;
}) {
const rc = roleConfig[member.role];
const RoleIcon = rc.icon;
const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners);
const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
return (
<div className="flex items-center gap-3 rounded-lg border px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-muted-foreground truncate">{member.email}</div>
</div>
{canEditRole ? (
<Select value={member.role} onValueChange={(value) => onRoleChange(value as MemberRole)} disabled={busy}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{canManageOwners && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
{rc.label}
</div>
)}
{canRemove && (
<Button
variant="ghost"
size="icon-sm"
onClick={onRemove}
disabled={busy}
aria-label={`Remove ${member.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
export default function SettingsPage() {
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(
workspace?.description ?? "",
);
const [context, setContext] = useState(workspace?.context ?? "");
const [profileName, setProfileName] = useState(user?.name ?? "");
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [saving, setSaving] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
const [tokenName, setTokenName] = useState("");
const [tokenExpiry, setTokenExpiry] = useState("90");
const [tokenCreating, setTokenCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
const loadTokens = useCallback(async () => {
try {
const list = await api.listPersonalAccessTokens();
setTokens(list);
} catch {
// ignore — tokens section simply stays empty
}
}, []);
useEffect(() => { loadTokens(); }, [loadTokens]);
const handleCreateToken = async () => {
setTokenCreating(true);
try {
const expiresInDays = tokenExpiry === "never" ? undefined : Number(tokenExpiry);
const result = await api.createPersonalAccessToken({ name: tokenName, expires_in_days: expiresInDays });
setNewToken(result.token);
setTokenName("");
setTokenExpiry("90");
await loadTokens();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create token");
} finally {
setTokenCreating(false);
}
};
const handleRevokeToken = async (id: string) => {
setTokenRevoking(id);
try {
await api.revokePersonalAccessToken(id);
await loadTokens();
toast.success("Token revoked");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to revoke token");
} finally {
setTokenRevoking(null);
}
};
const handleCopyToken = async () => {
if (!newToken) return;
await navigator.clipboard.writeText(newToken);
setTokenCopied(true);
setTimeout(() => setTokenCopied(false), 2000);
};
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
const [inviteLoading, setInviteLoading] = useState(false);
const [memberActionId, setMemberActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
title: string;
description: string;
variant?: "destructive";
onConfirm: () => Promise<void>;
} | null>(null);
const currentMember = members.find((member) => member.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
useEffect(() => {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
}, [workspace]);
useEffect(() => {
setProfileName(user?.name ?? "");
setAvatarUrl(user?.avatar_url ?? "");
}, [user]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, {
name,
description,
context,
});
updateWorkspace(updated);
toast.success("Workspace settings saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save workspace settings");
} finally {
setSaving(false);
}
};
const handleProfileSave = async () => {
setProfileSaving(true);
try {
const updated = await api.updateMe({
name: profileName,
avatar_url: avatarUrl || undefined,
});
setUser(updated);
toast.success("Profile updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update profile");
} finally {
setProfileSaving(false);
}
};
const handleAddMember = async () => {
if (!workspace) return;
setInviteLoading(true);
try {
await api.createMember(workspace.id, {
email: inviteEmail,
role: inviteRole,
});
setInviteEmail("");
setInviteRole("member");
await refreshMembers();
toast.success("Member added");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
} finally {
setInviteLoading(false);
}
};
const handleRoleChange = async (memberId: string, role: MemberRole) => {
if (!workspace) return;
setMemberActionId(memberId);
try {
await api.updateMember(workspace.id, memberId, { role });
await refreshMembers();
toast.success("Role updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update member");
} finally {
setMemberActionId(null);
}
};
const handleRemoveMember = (member: MemberWithUser) => {
if (!workspace) return;
setConfirmAction({
title: `Remove ${member.name}`,
description: `Remove ${member.name} from ${workspace.name}? They will lose access to this workspace.`,
variant: "destructive",
onConfirm: async () => {
setMemberActionId(member.id);
try {
await api.deleteMember(workspace.id, member.id);
await refreshMembers();
toast.success("Member removed");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");
} finally {
setMemberActionId(null);
}
},
});
};
const handleLeaveWorkspace = () => {
if (!workspace) return;
setConfirmAction({
title: "Leave workspace",
description: `Leave ${workspace.name}? You will lose access until re-invited.`,
variant: "destructive",
onConfirm: async () => {
setMemberActionId("leave");
try {
await leaveWorkspace(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to leave workspace");
} finally {
setMemberActionId(null);
}
},
});
};
const handleDeleteWorkspace = () => {
if (!workspace) return;
setConfirmAction({
title: "Delete workspace",
description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`,
variant: "destructive",
onConfirm: async () => {
setMemberActionId("delete-workspace");
try {
await deleteWorkspace(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
} finally {
setMemberActionId(null);
}
},
});
};
if (!workspace) return null;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="mx-auto max-w-2xl p-6 space-y-8">
{/* Page header */}
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Settings</h1>
</div>
<section className="space-y-4">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Profile</h2>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<Label className="text-xs text-muted-foreground">
Name
</Label>
<Input
type="search"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Avatar URL
</Label>
<Input
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleProfileSave}
disabled={profileSaving || !profileName.trim()}
>
<Save className="h-3 w-3" />
{profileSaving ? "Updating..." : "Update Profile"}
</Button>
</div>
</div>
</section>
{/* API Tokens */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Key className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">API Tokens</h2>
</div>
<div className="rounded-lg border p-4 space-y-3">
<p className="text-xs text-muted-foreground">
Personal access tokens allow the CLI and external integrations to authenticate with your account.
</p>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<Input
type="text"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="Token name (e.g. My CLI)"
/>
<Select value={tokenExpiry} onValueChange={(v) => { if (v) setTokenExpiry(v); }}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="365">1 year</SelectItem>
<SelectItem value="never">No expiry</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreateToken} disabled={tokenCreating || !tokenName.trim()}>
{tokenCreating ? "Creating..." : "Create"}
</Button>
</div>
</div>
{tokens.length > 0 && (
<div className="space-y-2">
{tokens.map((t) => (
<div key={t.id} className="flex items-center gap-3 rounded-lg border px-4 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{t.name}</div>
<div className="text-xs text-muted-foreground">
{t.token_prefix}... · Created {new Date(t.created_at).toLocaleDateString()} · {t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : "Never used"}
{t.expires_at && ` · Expires ${new Date(t.expires_at).toLocaleDateString()}`}
</div>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRevokeToken(t.id)}
disabled={tokenRevoking === t.id}
aria-label={`Revoke ${t.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</section>
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Token created</DialogTitle>
<DialogDescription>
Copy your personal access token now. You won&apos;t be able to see it again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted/50 px-3 py-2 text-sm break-all select-all">
{newToken}
</code>
<Button variant="outline" size="icon" onClick={handleCopyToken}>
{tokenCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<DialogFooter>
<Button onClick={() => { setNewToken(null); setTokenCopied(false); }}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Workspace info */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Workspace</h2>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<Label className="text-xs text-muted-foreground">
Name
</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canManageWorkspace}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Description
</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={!canManageWorkspace}
className="mt-1 resize-none"
placeholder="What does this workspace focus on?"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Context
</Label>
<Textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={4}
disabled={!canManageWorkspace}
className="mt-1 resize-none"
placeholder="Background information and context for AI agents working in this workspace"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Slug
</Label>
<div className="mt-1 rounded-md border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
{workspace.slug}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
Only admins and owners can update workspace settings.
</p>
)}
</div>
</section>
{/* Members */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">
Members ({members.length})
</h2>
</div>
</div>
{canManageWorkspace && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Add member</h3>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="user@company.com"
/>
<Select value={inviteRole} onValueChange={(value) => setInviteRole(value as MemberRole)}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{isOwner && <SelectItem value="owner">Owner</SelectItem>}
</SelectContent>
</Select>
<Button
onClick={handleAddMember}
disabled={inviteLoading || !inviteEmail.trim()}
>
{inviteLoading ? "Adding..." : "Add"}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{members.map((m) => (
<MemberRow
key={m.id}
member={m}
canManage={canManageWorkspace}
canManageOwners={isOwner}
isSelf={m.user_id === user?.id}
busy={memberActionId === m.id}
onRoleChange={(role) => handleRoleChange(m.id, role)}
onRemove={() => handleRemoveMember(m)}
/>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground">No members found.</p>
)}
</div>
</section>
<section className="space-y-4">
<div className="flex items-center gap-2">
<LogOut className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Danger Zone</h2>
</div>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">Leave workspace</p>
<p className="text-xs text-muted-foreground">
Remove yourself from this workspace.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleLeaveWorkspace}
disabled={memberActionId === "leave"}
>
{memberActionId === "leave" ? "Leaving..." : "Leave workspace"}
</Button>
</div>
{isOwner && (
<div className="flex flex-col gap-2 border-t pt-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-destructive">Delete workspace</p>
<p className="text-xs text-muted-foreground">
Permanently delete this workspace and its data.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteWorkspace}
disabled={memberActionId === "delete-workspace"}
>
{memberActionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
</Button>
</div>
)}
</div>
</section>
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
<AlertDialogDescription>{confirmAction?.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={confirmAction?.variant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
onClick={async () => {
await confirmAction?.onConfirm();
setConfirmAction(null);
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
}