handoff-app 2.0.0-2 → 2.0.0-3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -83
- package/dist/app-builder/build.js +21 -4
- package/dist/app-builder/client-config.js +9 -8
- package/dist/cli/auth/browser.d.ts +2 -0
- package/dist/cli/auth/browser.js +33 -0
- package/dist/cli/auth/device.d.ts +11 -0
- package/dist/cli/auth/device.js +155 -0
- package/dist/cli/auth/store.d.ts +29 -0
- package/dist/cli/auth/store.js +146 -0
- package/dist/cli/scaffold.js +20 -154
- package/dist/commands/index.js +4 -0
- package/dist/commands/login/index.d.ts +8 -0
- package/dist/commands/login/index.js +53 -0
- package/dist/commands/logout/index.d.ts +7 -0
- package/dist/commands/logout/index.js +54 -0
- package/dist/config/entries.d.ts +47 -0
- package/dist/config/entries.js +162 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +5 -1
- package/dist/config/runtime.d.ts +9 -9
- package/dist/config/runtime.js +15 -15
- package/dist/registry/auth/access-tokens.d.ts +46 -0
- package/dist/registry/auth/access-tokens.js +165 -0
- package/dist/registry/auth/action-tokens.d.ts +66 -0
- package/dist/registry/auth/action-tokens.js +178 -0
- package/dist/registry/auth/crypto.d.ts +4 -0
- package/dist/registry/auth/crypto.js +16 -0
- package/dist/registry/auth/database.d.ts +7 -0
- package/dist/registry/auth/database.js +22 -0
- package/dist/registry/auth/device-authorizations.d.ts +51 -0
- package/dist/registry/auth/device-authorizations.js +170 -0
- package/dist/registry/auth/gravatar.d.ts +1 -0
- package/dist/registry/auth/gravatar.js +8 -0
- package/dist/registry/auth/index.d.ts +11 -0
- package/dist/registry/auth/index.js +72 -0
- package/dist/registry/auth/installation.d.ts +42 -0
- package/dist/registry/auth/installation.js +105 -0
- package/dist/registry/auth/password.d.ts +12 -0
- package/dist/registry/auth/password.js +58 -0
- package/dist/registry/auth/rate-limits.d.ts +22 -0
- package/dist/registry/auth/rate-limits.js +75 -0
- package/dist/registry/auth/types.d.ts +26 -0
- package/dist/registry/auth/types.js +9 -0
- package/dist/registry/auth/user-record.d.ts +13 -0
- package/dist/registry/auth/user-record.js +16 -0
- package/dist/registry/auth/users.d.ts +31 -0
- package/dist/registry/auth/users.js +135 -0
- package/dist/registry/auth/validation.d.ts +3 -0
- package/dist/registry/auth/validation.js +28 -0
- package/dist/registry/checkout/assets.js +4 -2
- package/dist/registry/checkout/index.d.ts +1 -1
- package/dist/registry/checkout/index.js +101 -23
- package/dist/registry/checkout/tokens.js +4 -2
- package/dist/registry/connection.d.ts +9 -0
- package/dist/registry/connection.js +29 -1
- package/dist/registry/db/driver.d.ts +5 -4
- package/dist/registry/db/driver.js +5 -4
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +7 -1
- package/dist/registry/db/schema.d.ts +2521 -750
- package/dist/registry/db/schema.js +126 -1
- package/dist/registry/publish/assets.js +1 -1
- package/dist/registry/publish/errors.js +3 -3
- package/dist/registry/publish/index.d.ts +1 -1
- package/dist/registry/publish/index.js +9 -9
- package/dist/registry/publish/tokens.js +1 -1
- package/dist/types/config.d.ts +4 -1
- package/docs/cli.md +4 -0
- package/drizzle/0005_add_registry_auth.sql +95 -0
- package/drizzle/meta/0005_snapshot.json +1926 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/app/components/Auth/AccountLayout.tsx +87 -0
- package/src/app/components/Auth/AuthControls.tsx +99 -0
- package/src/app/components/Auth/AuthShell.tsx +73 -0
- package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
- package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
- package/src/app/components/Auth/api.ts +20 -0
- package/src/app/components/Layout/Header.tsx +2 -0
- package/src/app/components/Navigation/MobileNav.tsx +2 -0
- package/src/app/lib/auth/api.ts +113 -0
- package/src/app/lib/auth/config.ts +175 -0
- package/src/app/lib/auth/email.ts +34 -0
- package/src/app/lib/docs-api/runtime-config.ts +0 -14
- package/src/app/lib/registry-api/auth.ts +29 -46
- package/src/app/lib/registry-api/errors.ts +2 -2
- package/src/app/lib/registry-api/handler.ts +19 -14
- package/src/app/lib/registry-api/token-transfer.ts +1 -1
- package/src/app/lib/registry-api/transfer.ts +4 -4
- package/src/app/next.config.mjs +12 -3
- package/src/app/pages/_app.tsx +10 -4
- package/src/app/pages/account/index.tsx +153 -0
- package/src/app/pages/account/tokens.tsx +230 -0
- package/src/app/pages/account/users.tsx +310 -0
- package/src/app/pages/api/account/index.ts +26 -0
- package/src/app/pages/api/account/tokens/[id].ts +17 -0
- package/src/app/pages/api/account/tokens/index.ts +44 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
- package/src/app/pages/api/admin/users/index.ts +53 -0
- package/src/app/pages/api/auth/[...nextauth].ts +4 -0
- package/src/app/pages/api/auth/request-reset.ts +53 -0
- package/src/app/pages/api/auth/reset-password.ts +29 -0
- package/src/app/pages/api/install.ts +102 -0
- package/src/app/pages/api/oauth/device/approve.ts +23 -0
- package/src/app/pages/api/oauth/device/index.ts +41 -0
- package/src/app/pages/api/oauth/revoke.ts +24 -0
- package/src/app/pages/api/oauth/token.ts +31 -0
- package/src/app/pages/api/registry/health.ts +4 -3
- package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
- package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
- package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
- package/src/app/pages/cli/device.tsx +143 -0
- package/src/app/pages/install.tsx +336 -0
- package/src/app/pages/login.tsx +117 -0
- package/src/app/pages/reset-password.tsx +159 -0
- package/src/app/types/next-auth.d.ts +27 -0
- package/src/app-builder/build.ts +22 -5
- package/src/app-builder/client-config.ts +10 -9
- package/src/cli/auth/browser.ts +29 -0
- package/src/cli/auth/device.ts +181 -0
- package/src/cli/auth/store.ts +137 -0
- package/src/cli/scaffold.ts +20 -197
- package/src/commands/index.ts +4 -0
- package/src/commands/login/index.ts +48 -0
- package/src/commands/logout/index.ts +51 -0
- package/src/config/entries.ts +175 -0
- package/src/config/index.ts +4 -0
- package/src/config/runtime.ts +15 -14
- package/src/registry/auth/access-tokens.ts +198 -0
- package/src/registry/auth/action-tokens.ts +225 -0
- package/src/registry/auth/crypto.ts +13 -0
- package/src/registry/auth/database.ts +15 -0
- package/src/registry/auth/device-authorizations.ts +223 -0
- package/src/registry/auth/gravatar.ts +6 -0
- package/src/registry/auth/index.ts +89 -0
- package/src/registry/auth/installation.ts +117 -0
- package/src/registry/auth/password.ts +46 -0
- package/src/registry/auth/rate-limits.ts +84 -0
- package/src/registry/auth/types.ts +34 -0
- package/src/registry/auth/user-record.ts +25 -0
- package/src/registry/auth/users.ts +145 -0
- package/src/registry/auth/validation.ts +24 -0
- package/src/registry/checkout/assets.ts +4 -2
- package/src/registry/checkout/index.ts +114 -24
- package/src/registry/checkout/tokens.ts +4 -2
- package/src/registry/connection.ts +23 -0
- package/src/registry/db/driver.ts +5 -4
- package/src/registry/db/index.ts +12 -0
- package/src/registry/db/schema.ts +174 -1
- package/src/registry/publish/assets.ts +1 -1
- package/src/registry/publish/errors.ts +3 -3
- package/src/registry/publish/index.ts +9 -9
- package/src/registry/publish/tokens.ts +1 -1
- package/src/types/config.ts +4 -1
- package/templates/default/README.md.tpl +18 -0
- package/templates/default/gitignore.tpl +1 -0
- package/templates/default/handoff.config.js.tpl +0 -1
- package/templates/default/handoff.config.ts.tpl +0 -1
- package/templates/default/handoff.config.with-examples.js.tpl +0 -1
- package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { AccountLayout } from '../../components/Auth/AccountLayout';
|
|
2
|
+
import { authApiUrl, readApiError } from '../../components/Auth/api';
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle } from '../../components/ui/alert';
|
|
4
|
+
import { Badge } from '../../components/ui/badge';
|
|
5
|
+
import { Button } from '../../components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../../components/ui/card';
|
|
7
|
+
import { Input } from '../../components/ui/input';
|
|
8
|
+
import { Label } from '../../components/ui/label';
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
|
10
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
|
|
11
|
+
import { Check, Copy, MailPlus, RefreshCw } from 'lucide-react';
|
|
12
|
+
import { useSession } from 'next-auth/react';
|
|
13
|
+
import { useRouter } from 'next/router';
|
|
14
|
+
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
|
15
|
+
|
|
16
|
+
interface RegistryUser {
|
|
17
|
+
id: string;
|
|
18
|
+
name?: string | null;
|
|
19
|
+
email: string;
|
|
20
|
+
role: 'admin' | 'member';
|
|
21
|
+
status: 'invited' | 'active' | 'deactivated';
|
|
22
|
+
emailVerified?: string | null;
|
|
23
|
+
createdAt?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MutationResult {
|
|
27
|
+
activationUrl?: string;
|
|
28
|
+
inviteUrl?: string;
|
|
29
|
+
message?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function UsersPage() {
|
|
33
|
+
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
34
|
+
return <AccountLayout title="Users">{null}</AccountLayout>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return <RegistryUsersPage />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function RegistryUsersPage() {
|
|
41
|
+
const router = useRouter();
|
|
42
|
+
const { data: session, status: sessionStatus } = useSession();
|
|
43
|
+
const currentUser = session?.user as (typeof session.user & { id?: string; role?: string }) | undefined;
|
|
44
|
+
const [users, setUsers] = useState<RegistryUser[]>([]);
|
|
45
|
+
const [inviteRole, setInviteRole] = useState<'member' | 'admin'>('member');
|
|
46
|
+
const [pending, setPending] = useState(false);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
49
|
+
const [manualLink, setManualLink] = useState<string | null>(null);
|
|
50
|
+
const [copied, setCopied] = useState(false);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (sessionStatus === 'authenticated' && currentUser?.role !== 'admin') void router.replace('/account');
|
|
54
|
+
}, [currentUser?.role, router, sessionStatus]);
|
|
55
|
+
|
|
56
|
+
const load = useCallback(async () => {
|
|
57
|
+
const response = await fetch(authApiUrl('/api/admin/users'), { credentials: 'include', cache: 'no-store' });
|
|
58
|
+
if (!response.ok) throw new Error(await readApiError(response, 'Could not load users.'));
|
|
59
|
+
const body = (await response.json()) as RegistryUser[] | { users: RegistryUser[] };
|
|
60
|
+
const rows = Array.isArray(body) ? body : body.users;
|
|
61
|
+
setUsers(
|
|
62
|
+
rows.map((user) => ({
|
|
63
|
+
...user,
|
|
64
|
+
status: user.status ?? (user.emailVerified ? 'active' : 'invited'),
|
|
65
|
+
}))
|
|
66
|
+
);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (currentUser?.role === 'admin') void load().catch((reason: Error) => setError(reason.message));
|
|
71
|
+
}, [currentUser?.role, load]);
|
|
72
|
+
|
|
73
|
+
const showResult = (body: MutationResult, fallback: string) => {
|
|
74
|
+
setSuccess(body.message ?? fallback);
|
|
75
|
+
setManualLink(body.activationUrl ?? body.inviteUrl ?? null);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const invite = async (event: FormEvent<HTMLFormElement>) => {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
setPending(true);
|
|
81
|
+
setError(null);
|
|
82
|
+
setSuccess(null);
|
|
83
|
+
setManualLink(null);
|
|
84
|
+
const form = event.currentTarget;
|
|
85
|
+
const data = new FormData(form);
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(authApiUrl('/api/admin/users'), {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
credentials: 'include',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
email: String(data.get('email') ?? '')
|
|
93
|
+
.trim()
|
|
94
|
+
.toLowerCase(),
|
|
95
|
+
name: String(data.get('name') ?? '').trim(),
|
|
96
|
+
role: inviteRole,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
setError(await readApiError(response, 'Could not invite the user.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const body = (await response.json().catch(() => ({}))) as MutationResult;
|
|
104
|
+
showResult(body, 'Invitation created.');
|
|
105
|
+
form.reset();
|
|
106
|
+
setInviteRole('member');
|
|
107
|
+
await load();
|
|
108
|
+
} catch {
|
|
109
|
+
setError('Could not connect to the registry.');
|
|
110
|
+
} finally {
|
|
111
|
+
setPending(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const mutate = async (user: RegistryUser, action: 'resend' | 'role' | 'status', body: Record<string, string>, successMessage: string) => {
|
|
116
|
+
setPending(true);
|
|
117
|
+
setError(null);
|
|
118
|
+
setSuccess(null);
|
|
119
|
+
setManualLink(null);
|
|
120
|
+
try {
|
|
121
|
+
const response = await fetch(authApiUrl(`/api/admin/users/${encodeURIComponent(user.id)}/${action}`), {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
credentials: 'include',
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify(body),
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
setError(await readApiError(response, 'Could not update the user.'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const result = (await response.json().catch(() => ({}))) as MutationResult;
|
|
132
|
+
showResult(result, successMessage);
|
|
133
|
+
await load();
|
|
134
|
+
} catch {
|
|
135
|
+
setError('Could not connect to the registry.');
|
|
136
|
+
} finally {
|
|
137
|
+
setPending(false);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const copyLink = async () => {
|
|
142
|
+
if (!manualLink) return;
|
|
143
|
+
await navigator.clipboard.writeText(manualLink);
|
|
144
|
+
setCopied(true);
|
|
145
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<AccountLayout title="Users">
|
|
150
|
+
<div className="space-y-6">
|
|
151
|
+
<Card>
|
|
152
|
+
<CardHeader>
|
|
153
|
+
<CardTitle>Invite a user</CardTitle>
|
|
154
|
+
<CardDescription>
|
|
155
|
+
Email delivery is used when configured. Otherwise, the activation link appears once for manual delivery.
|
|
156
|
+
</CardDescription>
|
|
157
|
+
</CardHeader>
|
|
158
|
+
<form onSubmit={invite}>
|
|
159
|
+
<CardContent className="space-y-4">
|
|
160
|
+
{error ? (
|
|
161
|
+
<Alert variant="destructive">
|
|
162
|
+
<AlertDescription>{error}</AlertDescription>
|
|
163
|
+
</Alert>
|
|
164
|
+
) : null}
|
|
165
|
+
{success ? (
|
|
166
|
+
<Alert variant="green">
|
|
167
|
+
<AlertDescription>{success}</AlertDescription>
|
|
168
|
+
</Alert>
|
|
169
|
+
) : null}
|
|
170
|
+
{manualLink ? (
|
|
171
|
+
<Alert variant="warning">
|
|
172
|
+
<MailPlus />
|
|
173
|
+
<AlertTitle>Deliver this activation link securely</AlertTitle>
|
|
174
|
+
<AlertDescription>
|
|
175
|
+
<p className="mb-3">It is shown only in this response.</p>
|
|
176
|
+
<div className="flex gap-2">
|
|
177
|
+
<Input value={manualLink} readOnly className="text-xs" />
|
|
178
|
+
<Button type="button" size="icon" variant="outline" onClick={() => void copyLink()} aria-label="Copy activation link">
|
|
179
|
+
{copied ? <Check /> : <Copy />}
|
|
180
|
+
</Button>
|
|
181
|
+
</div>
|
|
182
|
+
</AlertDescription>
|
|
183
|
+
</Alert>
|
|
184
|
+
) : null}
|
|
185
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
186
|
+
<div className="space-y-2">
|
|
187
|
+
<Label htmlFor="invite-name">Display name</Label>
|
|
188
|
+
<Input id="invite-name" name="name" required maxLength={100} placeholder="Alex Smith" />
|
|
189
|
+
</div>
|
|
190
|
+
<div className="space-y-2">
|
|
191
|
+
<Label htmlFor="invite-email">Email</Label>
|
|
192
|
+
<Input id="invite-email" name="email" type="email" required placeholder="colleague@company.com" />
|
|
193
|
+
</div>
|
|
194
|
+
<div className="space-y-2">
|
|
195
|
+
<Label htmlFor="invite-role">Role</Label>
|
|
196
|
+
<Select value={inviteRole} onValueChange={(value) => setInviteRole(value as typeof inviteRole)}>
|
|
197
|
+
<SelectTrigger id="invite-role">
|
|
198
|
+
<SelectValue />
|
|
199
|
+
</SelectTrigger>
|
|
200
|
+
<SelectContent>
|
|
201
|
+
<SelectItem value="member">Member</SelectItem>
|
|
202
|
+
<SelectItem value="admin">Administrator</SelectItem>
|
|
203
|
+
</SelectContent>
|
|
204
|
+
</Select>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</CardContent>
|
|
208
|
+
<CardFooter className="justify-end border-t px-6 py-4">
|
|
209
|
+
<Button type="submit" disabled={pending}>
|
|
210
|
+
<MailPlus />
|
|
211
|
+
{pending ? 'Inviting…' : 'Invite user'}
|
|
212
|
+
</Button>
|
|
213
|
+
</CardFooter>
|
|
214
|
+
</form>
|
|
215
|
+
</Card>
|
|
216
|
+
|
|
217
|
+
<Card>
|
|
218
|
+
<CardHeader>
|
|
219
|
+
<CardTitle>Registry users</CardTitle>
|
|
220
|
+
<CardDescription>
|
|
221
|
+
Manage roles and access. Deactivation preserves the user record and revokes their credentials.
|
|
222
|
+
</CardDescription>
|
|
223
|
+
</CardHeader>
|
|
224
|
+
<CardContent>
|
|
225
|
+
<Table>
|
|
226
|
+
<TableHeader>
|
|
227
|
+
<TableRow>
|
|
228
|
+
<TableHead>User</TableHead>
|
|
229
|
+
<TableHead>Role</TableHead>
|
|
230
|
+
<TableHead>Status</TableHead>
|
|
231
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
232
|
+
</TableRow>
|
|
233
|
+
</TableHeader>
|
|
234
|
+
<TableBody>
|
|
235
|
+
{users.map((user) => (
|
|
236
|
+
<TableRow key={user.id}>
|
|
237
|
+
<TableCell>
|
|
238
|
+
<p className="font-medium">{user.name || user.email}</p>
|
|
239
|
+
<p className="text-xs text-muted-foreground">{user.email}</p>
|
|
240
|
+
</TableCell>
|
|
241
|
+
<TableCell>
|
|
242
|
+
<Select
|
|
243
|
+
value={user.role}
|
|
244
|
+
disabled={pending}
|
|
245
|
+
onValueChange={(role) => {
|
|
246
|
+
if (role !== user.role) void mutate(user, 'role', { role }, `Updated ${user.email}.`);
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<SelectTrigger className="w-36">
|
|
250
|
+
<SelectValue />
|
|
251
|
+
</SelectTrigger>
|
|
252
|
+
<SelectContent>
|
|
253
|
+
<SelectItem value="member">Member</SelectItem>
|
|
254
|
+
<SelectItem value="admin">Administrator</SelectItem>
|
|
255
|
+
</SelectContent>
|
|
256
|
+
</Select>
|
|
257
|
+
</TableCell>
|
|
258
|
+
<TableCell>
|
|
259
|
+
<Badge variant={user.status === 'active' ? 'green' : user.status === 'deactivated' ? 'destructive' : 'warning'}>
|
|
260
|
+
{user.status}
|
|
261
|
+
</Badge>
|
|
262
|
+
</TableCell>
|
|
263
|
+
<TableCell>
|
|
264
|
+
<div className="flex justify-end gap-2">
|
|
265
|
+
{user.status === 'invited' ? (
|
|
266
|
+
<Button
|
|
267
|
+
size="sm"
|
|
268
|
+
variant="ghost"
|
|
269
|
+
disabled={pending}
|
|
270
|
+
onClick={() => void mutate(user, 'resend', {}, `Invitation resent to ${user.email}.`)}
|
|
271
|
+
>
|
|
272
|
+
<RefreshCw /> Resend
|
|
273
|
+
</Button>
|
|
274
|
+
) : null}
|
|
275
|
+
{user.status !== 'invited' ? (
|
|
276
|
+
<Button
|
|
277
|
+
size="sm"
|
|
278
|
+
variant="outline"
|
|
279
|
+
disabled={pending || (user.id === currentUser?.id && user.status === 'active')}
|
|
280
|
+
onClick={() => {
|
|
281
|
+
const nextStatus = user.status === 'active' ? 'deactivated' : 'active';
|
|
282
|
+
const message =
|
|
283
|
+
nextStatus === 'active'
|
|
284
|
+
? `Reactivate ${user.email}?`
|
|
285
|
+
: `Deactivate ${user.email} and revoke their credentials?`;
|
|
286
|
+
if (window.confirm(message)) void mutate(user, 'status', { status: nextStatus }, `Updated ${user.email}.`);
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{user.status === 'active' ? 'Deactivate' : 'Reactivate'}
|
|
290
|
+
</Button>
|
|
291
|
+
) : null}
|
|
292
|
+
</div>
|
|
293
|
+
</TableCell>
|
|
294
|
+
</TableRow>
|
|
295
|
+
))}
|
|
296
|
+
{!users.length ? (
|
|
297
|
+
<TableRow>
|
|
298
|
+
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground">
|
|
299
|
+
No users found.
|
|
300
|
+
</TableCell>
|
|
301
|
+
</TableRow>
|
|
302
|
+
) : null}
|
|
303
|
+
</TableBody>
|
|
304
|
+
</Table>
|
|
305
|
+
</CardContent>
|
|
306
|
+
</Card>
|
|
307
|
+
</div>
|
|
308
|
+
</AccountLayout>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { buildGravatarUrl, updateRegistryUserProfile } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi } from '../../../lib/auth/api';
|
|
4
|
+
|
|
5
|
+
export default async function accountHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
6
|
+
const method = allowApiMethods(req, res, ['GET', 'PUT']);
|
|
7
|
+
if (!method) return;
|
|
8
|
+
const context = await prepareRegistryApi(req, res, { auth: 'user', mutation: method === 'PUT' });
|
|
9
|
+
if (!context?.user) return;
|
|
10
|
+
|
|
11
|
+
if (method === 'GET') {
|
|
12
|
+
res.status(200).json({ user: context.user, gravatarUrl: buildGravatarUrl(context.user.email) });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
16
|
+
const result = await updateRegistryUserProfile(context.db, {
|
|
17
|
+
userId: context.user.id,
|
|
18
|
+
name: typeof body.name === 'string' ? body.name : '',
|
|
19
|
+
image: typeof body.avatarUrl === 'string' ? body.avatarUrl : null,
|
|
20
|
+
});
|
|
21
|
+
if ('reason' in result) {
|
|
22
|
+
res.status(result.reason === 'not_found' ? 404 : 400).json({ error: 'Enter a valid display name and HTTPS avatar URL.' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
res.status(200).json({ user: result.user });
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { revokeRegistryAccessToken } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi } from '../../../../lib/auth/api';
|
|
4
|
+
|
|
5
|
+
export default async function accountTokenHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
6
|
+
const method = allowApiMethods(req, res, ['DELETE']);
|
|
7
|
+
if (!method) return;
|
|
8
|
+
const context = await prepareRegistryApi(req, res, { auth: 'user', mutation: true });
|
|
9
|
+
if (!context?.user) return;
|
|
10
|
+
const tokenId = typeof req.query.id === 'string' ? req.query.id : '';
|
|
11
|
+
const revoked = await revokeRegistryAccessToken(context.db, { tokenId, userId: context.user.id });
|
|
12
|
+
if (!revoked) {
|
|
13
|
+
res.status(404).json({ error: 'Access token not found.' });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
res.status(200).json({ ok: true });
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import {
|
|
3
|
+
createRegistryAccessToken,
|
|
4
|
+
listRegistryAccessTokens,
|
|
5
|
+
REGISTRY_READ_SCOPE,
|
|
6
|
+
REGISTRY_WRITE_SCOPE,
|
|
7
|
+
type RegistryAccessScope,
|
|
8
|
+
} from '@handoff/registry/auth';
|
|
9
|
+
import { allowApiMethods, prepareRegistryApi } from '../../../../lib/auth/api';
|
|
10
|
+
|
|
11
|
+
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export default async function accountTokensHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
14
|
+
const method = allowApiMethods(req, res, ['GET', 'POST']);
|
|
15
|
+
if (!method) return;
|
|
16
|
+
const context = await prepareRegistryApi(req, res, { auth: 'user', mutation: method === 'POST' });
|
|
17
|
+
if (!context?.user) return;
|
|
18
|
+
|
|
19
|
+
if (method === 'GET') {
|
|
20
|
+
const tokens = await listRegistryAccessTokens(context.db, context.user.id);
|
|
21
|
+
res.status(200).json({ tokens });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
26
|
+
const requested = Array.isArray(body.scopes) ? body.scopes : [];
|
|
27
|
+
const scopes = requested.filter(
|
|
28
|
+
(scope: unknown): scope is RegistryAccessScope => scope === REGISTRY_READ_SCOPE || scope === REGISTRY_WRITE_SCOPE
|
|
29
|
+
);
|
|
30
|
+
const normalizedScopes = scopes.includes(REGISTRY_WRITE_SCOPE) ? [REGISTRY_READ_SCOPE, REGISTRY_WRITE_SCOPE] : [REGISTRY_READ_SCOPE];
|
|
31
|
+
const result = await createRegistryAccessToken(context.db, {
|
|
32
|
+
userId: context.user.id,
|
|
33
|
+
name: typeof body.name === 'string' ? body.name : '',
|
|
34
|
+
scopes: normalizedScopes,
|
|
35
|
+
expiresAt: new Date(Date.now() + ONE_YEAR_MS),
|
|
36
|
+
});
|
|
37
|
+
if ('reason' in result) {
|
|
38
|
+
res.status(result.reason === 'scope_forbidden' ? 403 : 400).json({
|
|
39
|
+
error: result.reason === 'scope_forbidden' ? 'Only administrators can create write tokens.' : 'Enter a valid token name and scope.',
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
res.status(201).json({ token: result.token, accessToken: result.token, record: result.record });
|
|
44
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { resendUserInvitation, setRegistryUserStatus, updateRegistryUserRole, type RegistryUserRole } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi, registryPageUrl } from '../../../../../lib/auth/api';
|
|
4
|
+
import { registryEmailIsConfigured, sendRegistryAuthEmail } from '../../../../../lib/auth/email';
|
|
5
|
+
|
|
6
|
+
const mutationError = (reason: string): { status: number; error: string } => {
|
|
7
|
+
if (reason === 'not_found') return { status: 404, error: 'User not found.' };
|
|
8
|
+
if (reason === 'last_admin') return { status: 409, error: 'The final active administrator cannot be changed.' };
|
|
9
|
+
if (reason === 'self_deactivation') return { status: 409, error: 'You cannot deactivate your own account.' };
|
|
10
|
+
return { status: 400, error: 'The requested user change is not valid.' };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default async function userActionHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
14
|
+
const method = allowApiMethods(req, res, ['POST']);
|
|
15
|
+
if (!method) return;
|
|
16
|
+
const context = await prepareRegistryApi(req, res, { auth: 'admin', mutation: true });
|
|
17
|
+
if (!context?.user) return;
|
|
18
|
+
const userId = typeof req.query.id === 'string' ? req.query.id : '';
|
|
19
|
+
const action = typeof req.query.action === 'string' ? req.query.action : '';
|
|
20
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
21
|
+
|
|
22
|
+
if (action === 'role') {
|
|
23
|
+
if (body.role !== 'admin' && body.role !== 'member') {
|
|
24
|
+
res.status(400).json({ error: 'Role must be admin or member.' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const result = await updateRegistryUserRole(context.db, { userId, role: body.role as RegistryUserRole });
|
|
28
|
+
if ('reason' in result) {
|
|
29
|
+
const error = mutationError(result.reason);
|
|
30
|
+
res.status(error.status).json({ error: error.error });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
res.status(200).json({ user: result.user, message: 'User role updated.' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (action === 'status') {
|
|
38
|
+
if (body.status !== 'active' && body.status !== 'deactivated') {
|
|
39
|
+
res.status(400).json({ error: 'Status must be active or deactivated.' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const result = await setRegistryUserStatus(context.db, {
|
|
43
|
+
userId,
|
|
44
|
+
status: body.status,
|
|
45
|
+
actorUserId: context.user.id,
|
|
46
|
+
});
|
|
47
|
+
if ('reason' in result) {
|
|
48
|
+
const error = mutationError(result.reason);
|
|
49
|
+
res.status(error.status).json({ error: error.error });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
res.status(200).json({ user: result.user, message: `User ${body.status}.` });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (action === 'resend') {
|
|
57
|
+
const result = await resendUserInvitation(context.db, { userId });
|
|
58
|
+
if ('reason' in result) {
|
|
59
|
+
const error = mutationError(result.reason);
|
|
60
|
+
res.status(error.status).json({ error: error.error });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const activationUrl = registryPageUrl('/reset-password', undefined, { token: result.token, purpose: 'invite' });
|
|
64
|
+
if (!activationUrl) {
|
|
65
|
+
res.status(500).json({ error: 'AUTH_URL is not configured.' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (registryEmailIsConfigured()) {
|
|
69
|
+
const delivered = await sendRegistryAuthEmail({
|
|
70
|
+
to: result.user.email,
|
|
71
|
+
subject: 'Your Handoff Registry invitation',
|
|
72
|
+
heading: 'You are invited',
|
|
73
|
+
message: 'Set a password to activate your Handoff Registry account.',
|
|
74
|
+
actionLabel: 'Accept invitation',
|
|
75
|
+
actionUrl: activationUrl,
|
|
76
|
+
});
|
|
77
|
+
if (!delivered) {
|
|
78
|
+
res.status(502).json({ error: 'The invitation was renewed, but email delivery failed.' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
res.status(200).json({ user: result.user, message: 'Invitation resent.' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
res.status(200).json({ user: result.user, message: 'Invitation renewed for manual delivery.', activationUrl });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
res.status(404).json({ error: 'Unknown user action.' });
|
|
89
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { createUserInvitation, listRegistryUsers, type RegistryUserRole } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi, registryPageUrl } from '../../../../lib/auth/api';
|
|
4
|
+
import { registryEmailIsConfigured, sendRegistryAuthEmail } from '../../../../lib/auth/email';
|
|
5
|
+
|
|
6
|
+
export default async function usersHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
7
|
+
const method = allowApiMethods(req, res, ['GET', 'POST']);
|
|
8
|
+
if (!method) return;
|
|
9
|
+
const context = await prepareRegistryApi(req, res, { auth: 'admin', mutation: method === 'POST' });
|
|
10
|
+
if (!context?.user) return;
|
|
11
|
+
|
|
12
|
+
if (method === 'GET') {
|
|
13
|
+
res.status(200).json({ users: await listRegistryUsers(context.db) });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
18
|
+
const role: RegistryUserRole = body.role === 'admin' ? 'admin' : 'member';
|
|
19
|
+
const result = await createUserInvitation(context.db, {
|
|
20
|
+
email: typeof body.email === 'string' ? body.email : '',
|
|
21
|
+
name: typeof body.name === 'string' ? body.name : '',
|
|
22
|
+
role,
|
|
23
|
+
});
|
|
24
|
+
if ('reason' in result) {
|
|
25
|
+
res.status(result.reason === 'email_exists' ? 409 : 400).json({
|
|
26
|
+
error: result.reason === 'email_exists' ? 'A user with this email already exists.' : 'Enter a valid email and display name.',
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const activationUrl = registryPageUrl('/reset-password', undefined, { token: result.token, purpose: 'invite' });
|
|
32
|
+
if (!activationUrl) {
|
|
33
|
+
res.status(500).json({ error: 'AUTH_URL is not configured.' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (registryEmailIsConfigured()) {
|
|
37
|
+
const delivered = await sendRegistryAuthEmail({
|
|
38
|
+
to: result.user.email,
|
|
39
|
+
subject: 'Your Handoff Registry invitation',
|
|
40
|
+
heading: 'You are invited',
|
|
41
|
+
message: 'Set a password to activate your Handoff Registry account.',
|
|
42
|
+
actionLabel: 'Accept invitation',
|
|
43
|
+
actionUrl: activationUrl,
|
|
44
|
+
});
|
|
45
|
+
if (!delivered) {
|
|
46
|
+
res.status(502).json({ error: 'The invitation was created, but email delivery failed. Fix Resend configuration and resend it.' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.status(201).json({ user: result.user, message: 'Invitation sent.' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
res.status(201).json({ user: result.user, message: 'Invitation created for manual delivery.', activationUrl });
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { consumeAuthRateLimit, createPasswordReset, normalizeEmail } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi, registryPageUrl } from '../../../lib/auth/api';
|
|
4
|
+
import { registryEmailIsConfigured, sendRegistryAuthEmail } from '../../../lib/auth/email';
|
|
5
|
+
|
|
6
|
+
const requestAddress = (req: NextApiRequest): string => {
|
|
7
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
8
|
+
return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown';
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default async function requestResetHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
12
|
+
const method = allowApiMethods(req, res, ['POST']);
|
|
13
|
+
if (!method) return;
|
|
14
|
+
const context = await prepareRegistryApi(req, res, { mutation: true });
|
|
15
|
+
if (!context) return;
|
|
16
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
17
|
+
const email = normalizeEmail(typeof body.email === 'string' ? body.email : '');
|
|
18
|
+
const throttle = await consumeAuthRateLimit(context.db, {
|
|
19
|
+
bucket: 'password_reset',
|
|
20
|
+
identifier: `${requestAddress(req)}:${email}`,
|
|
21
|
+
limit: 5,
|
|
22
|
+
windowMs: 60 * 60 * 1000,
|
|
23
|
+
});
|
|
24
|
+
if (!throttle.allowed) {
|
|
25
|
+
res.setHeader('Retry-After', String(throttle.retryAfterSeconds));
|
|
26
|
+
res.status(429).json({ error: 'Too many reset requests. Try again later.' });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!registryEmailIsConfigured()) {
|
|
31
|
+
res.status(200).json({ ok: true });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const result = await createPasswordReset(context.db, email);
|
|
35
|
+
if (result.token && result.user) {
|
|
36
|
+
const resetUrl = registryPageUrl('/reset-password', undefined, { token: result.token });
|
|
37
|
+
if (resetUrl) {
|
|
38
|
+
try {
|
|
39
|
+
await sendRegistryAuthEmail({
|
|
40
|
+
to: result.user.email,
|
|
41
|
+
subject: 'Reset your Handoff Registry password',
|
|
42
|
+
heading: 'Reset your password',
|
|
43
|
+
message: 'Use this single-use link to choose a new password.',
|
|
44
|
+
actionLabel: 'Reset password',
|
|
45
|
+
actionUrl: resetUrl,
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
// Keep the response enumeration-safe. Operational email failures never reveal account state.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
res.status(200).json({ ok: true });
|
|
53
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { acceptUserInvitation, resetPassword } from '@handoff/registry/auth';
|
|
3
|
+
import { allowApiMethods, prepareRegistryApi } from '../../../lib/auth/api';
|
|
4
|
+
|
|
5
|
+
export default async function resetPasswordHandler(req: NextApiRequest, res: NextApiResponse) {
|
|
6
|
+
const method = allowApiMethods(req, res, ['POST']);
|
|
7
|
+
if (!method) return;
|
|
8
|
+
const context = await prepareRegistryApi(req, res, { mutation: true });
|
|
9
|
+
if (!context) return;
|
|
10
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
11
|
+
const token = typeof body.token === 'string' ? body.token : '';
|
|
12
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
13
|
+
if (password !== body.passwordConfirmation) {
|
|
14
|
+
res.status(400).json({ error: 'Passwords do not match.' });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const result =
|
|
18
|
+
body.purpose === 'invite'
|
|
19
|
+
? await acceptUserInvitation(context.db, { token, password })
|
|
20
|
+
: await resetPassword(context.db, { token, password });
|
|
21
|
+
if ('reason' in result) {
|
|
22
|
+
res.status(400).json({
|
|
23
|
+
error:
|
|
24
|
+
result.reason === 'invalid_password' && result.error ? result.error : 'This link is invalid, expired, or has already been used.',
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
res.status(200).json({ ok: true });
|
|
29
|
+
}
|