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,336 @@
|
|
|
1
|
+
import { CheckCircle2, CircleAlert, Mail, RefreshCw, UserRound } from 'lucide-react';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { useRouter } from 'next/router';
|
|
4
|
+
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
|
5
|
+
import { AuthShell } from '../components/Auth/AuthShell';
|
|
6
|
+
import { authApiUrl, readApiError } from '../components/Auth/api';
|
|
7
|
+
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
|
|
8
|
+
import { Button } from '../components/ui/button';
|
|
9
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card';
|
|
10
|
+
import { Input } from '../components/ui/input';
|
|
11
|
+
import { Label } from '../components/ui/label';
|
|
12
|
+
|
|
13
|
+
type InstallStep = 'preflight' | 'administrator' | 'complete';
|
|
14
|
+
|
|
15
|
+
interface PreflightCheck {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
message?: string;
|
|
20
|
+
optional?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface InstallStatus {
|
|
24
|
+
installed?: boolean;
|
|
25
|
+
ready?: boolean;
|
|
26
|
+
error?: string | { message?: string };
|
|
27
|
+
message?: string;
|
|
28
|
+
checks?: PreflightCheck[] | Record<string, boolean | { ok?: boolean; label?: string; message?: string; optional?: boolean }>;
|
|
29
|
+
emailConfigured?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const checkLabels: Record<string, string> = {
|
|
33
|
+
runtime: 'Registry runtime',
|
|
34
|
+
database: 'Database connection',
|
|
35
|
+
authSecret: 'Authentication secret',
|
|
36
|
+
appUrl: 'Canonical application URL',
|
|
37
|
+
email: 'Email delivery',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const normalizeChecks = (status: InstallStatus): PreflightCheck[] => {
|
|
41
|
+
if (Array.isArray(status.checks) && status.checks.length > 0) return status.checks;
|
|
42
|
+
if (status.checks) {
|
|
43
|
+
return Object.entries(status.checks).map(([id, value]) => ({
|
|
44
|
+
id,
|
|
45
|
+
label: typeof value === 'object' && value.label ? value.label : (checkLabels[id] ?? id),
|
|
46
|
+
ok: typeof value === 'boolean' ? value : value.ok === true,
|
|
47
|
+
message: typeof value === 'object' ? value.message : undefined,
|
|
48
|
+
optional: typeof value === 'object' ? value.optional : undefined,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
id: 'preflight',
|
|
55
|
+
label: 'Registry configuration',
|
|
56
|
+
ok: status.ready === true,
|
|
57
|
+
message: status.ready ? 'The registry is ready to install.' : 'Complete the registry configuration, then retry.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'email',
|
|
61
|
+
label: 'Email delivery',
|
|
62
|
+
ok: status.emailConfigured === true,
|
|
63
|
+
optional: true,
|
|
64
|
+
message: status.emailConfigured ? 'Invitation and reset emails are enabled.' : 'Optional. Invite links can be delivered manually.',
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default function InstallPage() {
|
|
70
|
+
const router = useRouter();
|
|
71
|
+
const [step, setStep] = useState<InstallStep>('preflight');
|
|
72
|
+
const [checks, setChecks] = useState<PreflightCheck[]>([]);
|
|
73
|
+
const [loading, setLoading] = useState(true);
|
|
74
|
+
const [submitting, setSubmitting] = useState(false);
|
|
75
|
+
const [error, setError] = useState<string | null>(null);
|
|
76
|
+
const [registryUrl, setRegistryUrl] = useState('<registry-url>');
|
|
77
|
+
|
|
78
|
+
const runPreflight = useCallback(async () => {
|
|
79
|
+
setLoading(true);
|
|
80
|
+
setError(null);
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(authApiUrl('/api/install'), { credentials: 'include', cache: 'no-store' });
|
|
83
|
+
const status = (await response.json().catch(() => ({}))) as InstallStatus;
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
setChecks(normalizeChecks(status));
|
|
86
|
+
setError(
|
|
87
|
+
typeof status.error === 'string'
|
|
88
|
+
? status.error
|
|
89
|
+
: (status.error?.message ?? status.message ?? 'Could not check the registry configuration.')
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (status.installed) {
|
|
95
|
+
void router.replace('/login');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setChecks(normalizeChecks(status));
|
|
99
|
+
} catch {
|
|
100
|
+
setChecks([]);
|
|
101
|
+
setError('Could not connect to the registry. Check the deployment and try again.');
|
|
102
|
+
} finally {
|
|
103
|
+
setLoading(false);
|
|
104
|
+
}
|
|
105
|
+
}, [router]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
// The workspace runs `handoff-app login` against this registry's public URL: its origin plus any
|
|
109
|
+
// deployed base path, never the /install route itself.
|
|
110
|
+
const basePath = (process.env.HANDOFF_APP_BASE_PATH ?? '').replace(/\/+$/, '');
|
|
111
|
+
setRegistryUrl(`${window.location.origin}${basePath}`);
|
|
112
|
+
if (process.env.HANDOFF_RUNTIME_MODE === 'registry') void runPreflight();
|
|
113
|
+
else setLoading(false);
|
|
114
|
+
}, [runPreflight]);
|
|
115
|
+
|
|
116
|
+
const blockingChecks = checks.filter((check) => !check.ok && !check.optional);
|
|
117
|
+
const ready = checks.length > 0 && blockingChecks.length === 0;
|
|
118
|
+
|
|
119
|
+
const install = async (event: FormEvent<HTMLFormElement>) => {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
setSubmitting(true);
|
|
122
|
+
setError(null);
|
|
123
|
+
const form = new FormData(event.currentTarget);
|
|
124
|
+
const password = String(form.get('password') ?? '');
|
|
125
|
+
const passwordConfirmation = String(form.get('passwordConfirmation') ?? '');
|
|
126
|
+
if (password !== passwordConfirmation) {
|
|
127
|
+
setError('Passwords do not match.');
|
|
128
|
+
setSubmitting(false);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetch(authApiUrl('/api/install'), {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
credentials: 'include',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
email: String(form.get('email') ?? '')
|
|
139
|
+
.trim()
|
|
140
|
+
.toLowerCase(),
|
|
141
|
+
name: String(form.get('name') ?? '').trim(),
|
|
142
|
+
password,
|
|
143
|
+
passwordConfirmation,
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (response.status === 409) {
|
|
148
|
+
setError('This registry has already been installed. Continue to sign in.');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
setError(await readApiError(response, 'Installation could not be completed.'));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
setStep('complete');
|
|
156
|
+
} catch {
|
|
157
|
+
setError('Could not connect to the registry. No installation changes were made.');
|
|
158
|
+
} finally {
|
|
159
|
+
setSubmitting(false);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<AuthShell title="Install registry" description="Configure the first Handoff Registry administrator." hideNav>
|
|
165
|
+
<div className="mb-8">
|
|
166
|
+
<p className="text-sm font-medium text-muted-foreground">Registry installation</p>
|
|
167
|
+
<div className="mt-3 grid grid-cols-3 gap-2" aria-label="Installation progress">
|
|
168
|
+
{[
|
|
169
|
+
['preflight', '1. Preflight'],
|
|
170
|
+
['administrator', '2. Administrator'],
|
|
171
|
+
['complete', '3. Complete'],
|
|
172
|
+
].map(([id, label]) => (
|
|
173
|
+
<div
|
|
174
|
+
key={id}
|
|
175
|
+
className={`rounded-md px-3 py-2 text-center text-xs font-medium ${step === id ? 'bg-gray-800 text-white' : 'bg-muted text-muted-foreground'
|
|
176
|
+
}`}
|
|
177
|
+
>
|
|
178
|
+
{label}
|
|
179
|
+
</div>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{step === 'preflight' ? (
|
|
185
|
+
<Card>
|
|
186
|
+
<CardHeader>
|
|
187
|
+
<CardTitle>Welcome to Handoff Registry</CardTitle>
|
|
188
|
+
<CardDescription>
|
|
189
|
+
Before you create the first administrator account, we’ll run a quick readiness check to make sure this deployment is
|
|
190
|
+
configured correctly.
|
|
191
|
+
</CardDescription>
|
|
192
|
+
</CardHeader>
|
|
193
|
+
<CardContent className="space-y-4">
|
|
194
|
+
{error ? (
|
|
195
|
+
<Alert variant="destructive">
|
|
196
|
+
<CircleAlert />
|
|
197
|
+
<AlertTitle>Preflight failed</AlertTitle>
|
|
198
|
+
<AlertDescription>{error}</AlertDescription>
|
|
199
|
+
</Alert>
|
|
200
|
+
) : null}
|
|
201
|
+
{loading ? (
|
|
202
|
+
<div className="flex items-center gap-3 rounded-lg bg-muted p-4 text-sm text-muted-foreground">
|
|
203
|
+
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
204
|
+
Checking the registry…
|
|
205
|
+
</div>
|
|
206
|
+
) : (
|
|
207
|
+
<div className="divide-y rounded-lg border">
|
|
208
|
+
{checks.map((check) => (
|
|
209
|
+
<div key={check.id} className="flex gap-3 p-4">
|
|
210
|
+
{check.ok ? (
|
|
211
|
+
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-green-600" />
|
|
212
|
+
) : check.optional ? (
|
|
213
|
+
<Mail className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
|
214
|
+
) : (
|
|
215
|
+
<CircleAlert className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
|
216
|
+
)}
|
|
217
|
+
<div>
|
|
218
|
+
<p className="text-sm font-medium">
|
|
219
|
+
{check.label} {check.optional ? <span className="font-normal text-muted-foreground">(optional)</span> : null}
|
|
220
|
+
</p>
|
|
221
|
+
{check.message ? <p className="mt-1 text-xs text-muted-foreground">{check.message}</p> : null}
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
{blockingChecks.length > 0 ? (
|
|
228
|
+
<Alert variant="warning">
|
|
229
|
+
<CircleAlert />
|
|
230
|
+
<AlertTitle>Deployment action required</AlertTitle>
|
|
231
|
+
<AlertDescription>Correct the checks above, then retry.</AlertDescription>
|
|
232
|
+
</Alert>
|
|
233
|
+
) : null}
|
|
234
|
+
</CardContent>
|
|
235
|
+
<CardFooter className="justify-between gap-3">
|
|
236
|
+
<Button variant="outline" onClick={() => void runPreflight()} disabled={loading}>
|
|
237
|
+
<RefreshCw />
|
|
238
|
+
Retry checks
|
|
239
|
+
</Button>
|
|
240
|
+
<Button onClick={() => setStep('administrator')} disabled={!ready || loading}>
|
|
241
|
+
Continue
|
|
242
|
+
</Button>
|
|
243
|
+
</CardFooter>
|
|
244
|
+
</Card>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
{step === 'administrator' ? (
|
|
248
|
+
<Card>
|
|
249
|
+
<CardHeader>
|
|
250
|
+
<CardTitle>Create an administrator account</CardTitle>
|
|
251
|
+
<CardDescription>This account will manage registry users and generate access tokens.</CardDescription>
|
|
252
|
+
</CardHeader>
|
|
253
|
+
<form onSubmit={install}>
|
|
254
|
+
<CardContent className="space-y-4">
|
|
255
|
+
{error ? (
|
|
256
|
+
<Alert variant="destructive">
|
|
257
|
+
<CircleAlert />
|
|
258
|
+
<AlertDescription>{error}</AlertDescription>
|
|
259
|
+
</Alert>
|
|
260
|
+
) : null}
|
|
261
|
+
<div className="space-y-2">
|
|
262
|
+
<Label htmlFor="name">Display name</Label>
|
|
263
|
+
<Input id="name" name="name" autoComplete="name" required maxLength={100} placeholder="Alex Smith" />
|
|
264
|
+
</div>
|
|
265
|
+
<div className="space-y-2">
|
|
266
|
+
<Label htmlFor="email">Email</Label>
|
|
267
|
+
<Input id="email" name="email" type="email" autoComplete="email" required placeholder="admin@company.com" />
|
|
268
|
+
</div>
|
|
269
|
+
<div className="space-y-2">
|
|
270
|
+
<Label htmlFor="password">Password</Label>
|
|
271
|
+
<Input id="password" name="password" type="password" autoComplete="new-password" required minLength={12} />
|
|
272
|
+
<p className="text-xs text-muted-foreground">Use at least 12 characters.</p>
|
|
273
|
+
</div>
|
|
274
|
+
<div className="space-y-2">
|
|
275
|
+
<Label htmlFor="passwordConfirmation">Confirm password</Label>
|
|
276
|
+
<Input
|
|
277
|
+
id="passwordConfirmation"
|
|
278
|
+
name="passwordConfirmation"
|
|
279
|
+
type="password"
|
|
280
|
+
autoComplete="new-password"
|
|
281
|
+
required
|
|
282
|
+
minLength={12}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
</CardContent>
|
|
286
|
+
<CardFooter className="justify-between">
|
|
287
|
+
<Button type="button" variant="outline" onClick={() => setStep('preflight')} disabled={submitting}>
|
|
288
|
+
Back
|
|
289
|
+
</Button>
|
|
290
|
+
<Button type="submit" disabled={submitting}>
|
|
291
|
+
<UserRound />
|
|
292
|
+
{submitting ? 'Installing…' : 'Install registry'}
|
|
293
|
+
</Button>
|
|
294
|
+
</CardFooter>
|
|
295
|
+
</form>
|
|
296
|
+
</Card>
|
|
297
|
+
) : null}
|
|
298
|
+
|
|
299
|
+
{step === 'complete' ? (
|
|
300
|
+
<Card>
|
|
301
|
+
<CardHeader>
|
|
302
|
+
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 text-green-700">
|
|
303
|
+
<CheckCircle2 />
|
|
304
|
+
</div>
|
|
305
|
+
<CardTitle>Registry setup complete</CardTitle>
|
|
306
|
+
<CardDescription>Your administrator account is ready. No access tokens were created during setup.</CardDescription>
|
|
307
|
+
</CardHeader>
|
|
308
|
+
<CardContent>
|
|
309
|
+
<ol className="space-y-4 text-sm">
|
|
310
|
+
<li className="flex gap-3">
|
|
311
|
+
<strong>1.</strong>
|
|
312
|
+
<span>Sign in with the administrator account you just created.</span>
|
|
313
|
+
</li>
|
|
314
|
+
<li className="flex gap-3">
|
|
315
|
+
<strong>2.</strong>
|
|
316
|
+
<span>
|
|
317
|
+
Run <code className="rounded bg-muted px-1.5 py-0.5">handoff-app login --url {registryUrl}</code> in your
|
|
318
|
+
workspace.
|
|
319
|
+
</span>
|
|
320
|
+
</li>
|
|
321
|
+
<li className="flex gap-3">
|
|
322
|
+
<strong>3.</strong>
|
|
323
|
+
<span>Approve the device code in this registry to authorize publishing and checkout.</span>
|
|
324
|
+
</li>
|
|
325
|
+
</ol>
|
|
326
|
+
</CardContent>
|
|
327
|
+
<CardFooter>
|
|
328
|
+
<Button asChild className="w-full">
|
|
329
|
+
<Link href="/login">Continue to sign in</Link>
|
|
330
|
+
</Button>
|
|
331
|
+
</CardFooter>
|
|
332
|
+
</Card>
|
|
333
|
+
) : null}
|
|
334
|
+
</AuthShell>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Alert, AlertDescription } from '../components/ui/alert';
|
|
2
|
+
import { Button } from '../components/ui/button';
|
|
3
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card';
|
|
4
|
+
import { Input } from '../components/ui/input';
|
|
5
|
+
import { Label } from '../components/ui/label';
|
|
6
|
+
import { CircleAlert } from 'lucide-react';
|
|
7
|
+
import Link from 'next/link';
|
|
8
|
+
import { signIn, useSession } from 'next-auth/react';
|
|
9
|
+
import { useRouter } from 'next/router';
|
|
10
|
+
import { FormEvent, useEffect, useState } from 'react';
|
|
11
|
+
import { AuthShell } from '../components/Auth/AuthShell';
|
|
12
|
+
import { authApiUrl } from '../components/Auth/api';
|
|
13
|
+
|
|
14
|
+
const safeCallbackUrl = (value: unknown): string =>
|
|
15
|
+
typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\') ? value : '/';
|
|
16
|
+
|
|
17
|
+
export default function LoginPage() {
|
|
18
|
+
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
19
|
+
return <AuthShell title="Sign in">{null}</AuthShell>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return <RegistryLoginPage />;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function RegistryLoginPage() {
|
|
26
|
+
const router = useRouter();
|
|
27
|
+
const { data: session, status } = useSession();
|
|
28
|
+
const [pending, setPending] = useState(false);
|
|
29
|
+
const [checkingInstall, setCheckingInstall] = useState(true);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
34
|
+
setCheckingInstall(false);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
void fetch(authApiUrl('/api/install'), { credentials: 'include', cache: 'no-store' })
|
|
38
|
+
.then(async (response) => {
|
|
39
|
+
if (!response.ok) return;
|
|
40
|
+
const body = (await response.json()) as { installed?: boolean };
|
|
41
|
+
if (body.installed === false) await router.replace('/install');
|
|
42
|
+
})
|
|
43
|
+
.finally(() => setCheckingInstall(false));
|
|
44
|
+
}, [router]);
|
|
45
|
+
|
|
46
|
+
// Only a session with a live user counts as signed in. An invalidated token still reports
|
|
47
|
+
// `authenticated` but has no user, so redirecting on status alone would bounce the visitor away
|
|
48
|
+
// before they could reach this form. That's the sign-in loop that clearing cookies used to fix.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (status === 'authenticated' && session?.user) {
|
|
51
|
+
void router.replace(safeCallbackUrl(router.query.callbackUrl));
|
|
52
|
+
}
|
|
53
|
+
}, [router, session, status]);
|
|
54
|
+
|
|
55
|
+
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
setPending(true);
|
|
58
|
+
setError(null);
|
|
59
|
+
const data = new FormData(event.currentTarget);
|
|
60
|
+
const result = await signIn('handoff-credentials', {
|
|
61
|
+
email: String(data.get('email') ?? '')
|
|
62
|
+
.trim()
|
|
63
|
+
.toLowerCase(),
|
|
64
|
+
password: String(data.get('password') ?? ''),
|
|
65
|
+
redirect: false,
|
|
66
|
+
});
|
|
67
|
+
setPending(false);
|
|
68
|
+
|
|
69
|
+
if (!result || result.error) {
|
|
70
|
+
setError('Invalid email or password.');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await router.push(safeCallbackUrl(router.query.callbackUrl));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<AuthShell title="Sign in">
|
|
78
|
+
<Card>
|
|
79
|
+
<CardHeader>
|
|
80
|
+
<CardTitle>Sign in</CardTitle>
|
|
81
|
+
<CardDescription>Use your Handoff Registry email and password.</CardDescription>
|
|
82
|
+
</CardHeader>
|
|
83
|
+
<form onSubmit={submit}>
|
|
84
|
+
<CardContent className="space-y-4">
|
|
85
|
+
{router.query.updated === '1' ? (
|
|
86
|
+
<Alert variant="green">
|
|
87
|
+
<AlertDescription>Your password was updated. Sign in below.</AlertDescription>
|
|
88
|
+
</Alert>
|
|
89
|
+
) : null}
|
|
90
|
+
{error ? (
|
|
91
|
+
<Alert variant="destructive">
|
|
92
|
+
<CircleAlert />
|
|
93
|
+
<AlertDescription>{error}</AlertDescription>
|
|
94
|
+
</Alert>
|
|
95
|
+
) : null}
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
<Label htmlFor="email">Email</Label>
|
|
98
|
+
<Input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
|
|
99
|
+
</div>
|
|
100
|
+
<div className="space-y-2">
|
|
101
|
+
<Label htmlFor="password">Password</Label>
|
|
102
|
+
<Input id="password" name="password" type="password" autoComplete="current-password" required />
|
|
103
|
+
</div>
|
|
104
|
+
</CardContent>
|
|
105
|
+
<CardFooter className="flex-col gap-3">
|
|
106
|
+
<Button type="submit" className="w-full" disabled={pending || checkingInstall}>
|
|
107
|
+
{pending ? 'Signing in…' : 'Sign in'}
|
|
108
|
+
</Button>
|
|
109
|
+
<Link href="/reset-password" className="text-sm text-muted-foreground underline-offset-4 hover:underline">
|
|
110
|
+
Forgot password?
|
|
111
|
+
</Link>
|
|
112
|
+
</CardFooter>
|
|
113
|
+
</form>
|
|
114
|
+
</Card>
|
|
115
|
+
</AuthShell>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Alert, AlertDescription } from '../components/ui/alert';
|
|
2
|
+
import { Button } from '../components/ui/button';
|
|
3
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card';
|
|
4
|
+
import { Input } from '../components/ui/input';
|
|
5
|
+
import { Label } from '../components/ui/label';
|
|
6
|
+
import Head from 'next/head';
|
|
7
|
+
import Link from 'next/link';
|
|
8
|
+
import { useRouter } from 'next/router';
|
|
9
|
+
import { FormEvent, useEffect, useState } from 'react';
|
|
10
|
+
import { AuthShell } from '../components/Auth/AuthShell';
|
|
11
|
+
import { authApiUrl, readApiError } from '../components/Auth/api';
|
|
12
|
+
|
|
13
|
+
export default function ResetPasswordPage() {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
const [token, setToken] = useState('');
|
|
16
|
+
const [purpose, setPurpose] = useState<'invite' | 'reset'>('reset');
|
|
17
|
+
const [pending, setPending] = useState(false);
|
|
18
|
+
const [sent, setSent] = useState(false);
|
|
19
|
+
const [error, setError] = useState<string | null>(null);
|
|
20
|
+
|
|
21
|
+
// The invite/reset secret arrives in the URL fragment, which never hits the server, so it stays
|
|
22
|
+
// out of access logs and referrers. Pull it into state, then clear the URL so it doesn't linger in
|
|
23
|
+
// browser history.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
|
26
|
+
const capturedToken = hash.get('token');
|
|
27
|
+
if (!capturedToken) return;
|
|
28
|
+
setToken(capturedToken);
|
|
29
|
+
setPurpose(hash.get('purpose') === 'invite' ? 'invite' : 'reset');
|
|
30
|
+
void router.replace('/reset-password', undefined, { shallow: true });
|
|
31
|
+
}, [router]);
|
|
32
|
+
|
|
33
|
+
const requestReset = async (event: FormEvent<HTMLFormElement>) => {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
setPending(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
const data = new FormData(event.currentTarget);
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(authApiUrl('/api/auth/request-reset'), {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
email: String(data.get('email') ?? '')
|
|
44
|
+
.trim()
|
|
45
|
+
.toLowerCase(),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
setError(await readApiError(response, 'Could not request a password reset.'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setSent(true);
|
|
53
|
+
} catch {
|
|
54
|
+
setError('Could not connect to the registry.');
|
|
55
|
+
} finally {
|
|
56
|
+
setPending(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const resetPassword = async (event: FormEvent<HTMLFormElement>) => {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
setPending(true);
|
|
63
|
+
setError(null);
|
|
64
|
+
const data = new FormData(event.currentTarget);
|
|
65
|
+
const password = String(data.get('password') ?? '');
|
|
66
|
+
const passwordConfirmation = String(data.get('passwordConfirmation') ?? '');
|
|
67
|
+
if (password !== passwordConfirmation) {
|
|
68
|
+
setError('Passwords do not match.');
|
|
69
|
+
setPending(false);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(authApiUrl('/api/auth/reset-password'), {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ token, password, passwordConfirmation, purpose }),
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
setError(await readApiError(response, 'This reset link is invalid or has expired.'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await router.push('/login?updated=1');
|
|
83
|
+
} catch {
|
|
84
|
+
setError('Could not connect to the registry.');
|
|
85
|
+
} finally {
|
|
86
|
+
setPending(false);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<AuthShell title={token ? 'Set a new password' : 'Reset password'}>
|
|
92
|
+
<Head>
|
|
93
|
+
<meta name="referrer" content="no-referrer" />
|
|
94
|
+
</Head>
|
|
95
|
+
<Card>
|
|
96
|
+
<CardHeader>
|
|
97
|
+
<CardTitle>{token ? 'Set a new password' : 'Reset password'}</CardTitle>
|
|
98
|
+
<CardDescription>
|
|
99
|
+
{sent
|
|
100
|
+
? 'If an account exists for that email, a password reset link has been sent.'
|
|
101
|
+
: token
|
|
102
|
+
? 'Choose a new password for your registry account.'
|
|
103
|
+
: 'Enter your email and we will send a reset link if an account exists.'}
|
|
104
|
+
</CardDescription>
|
|
105
|
+
</CardHeader>
|
|
106
|
+
{sent ? (
|
|
107
|
+
<CardFooter>
|
|
108
|
+
<Button asChild variant="outline" className="w-full">
|
|
109
|
+
<Link href="/login">Return to sign in</Link>
|
|
110
|
+
</Button>
|
|
111
|
+
</CardFooter>
|
|
112
|
+
) : (
|
|
113
|
+
<form onSubmit={token ? resetPassword : requestReset}>
|
|
114
|
+
<CardContent className="space-y-4">
|
|
115
|
+
{error ? (
|
|
116
|
+
<Alert variant="destructive">
|
|
117
|
+
<AlertDescription>{error}</AlertDescription>
|
|
118
|
+
</Alert>
|
|
119
|
+
) : null}
|
|
120
|
+
{token ? (
|
|
121
|
+
<>
|
|
122
|
+
<div className="space-y-2">
|
|
123
|
+
<Label htmlFor="password">New password</Label>
|
|
124
|
+
<Input id="password" name="password" type="password" autoComplete="new-password" required minLength={12} />
|
|
125
|
+
<p className="text-xs text-muted-foreground">Use at least 12 characters.</p>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="space-y-2">
|
|
128
|
+
<Label htmlFor="passwordConfirmation">Confirm password</Label>
|
|
129
|
+
<Input
|
|
130
|
+
id="passwordConfirmation"
|
|
131
|
+
name="passwordConfirmation"
|
|
132
|
+
type="password"
|
|
133
|
+
autoComplete="new-password"
|
|
134
|
+
required
|
|
135
|
+
minLength={12}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</>
|
|
139
|
+
) : (
|
|
140
|
+
<div className="space-y-2">
|
|
141
|
+
<Label htmlFor="email">Email</Label>
|
|
142
|
+
<Input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</CardContent>
|
|
146
|
+
<CardFooter className="flex-col gap-3">
|
|
147
|
+
<Button type="submit" className="w-full" disabled={pending}>
|
|
148
|
+
{pending ? 'Please wait…' : token ? 'Update password' : 'Send reset link'}
|
|
149
|
+
</Button>
|
|
150
|
+
<Link href="/login" className="text-sm text-muted-foreground underline-offset-4 hover:underline">
|
|
151
|
+
Back to sign in
|
|
152
|
+
</Link>
|
|
153
|
+
</CardFooter>
|
|
154
|
+
</form>
|
|
155
|
+
)}
|
|
156
|
+
</Card>
|
|
157
|
+
</AuthShell>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DefaultSession } from 'next-auth';
|
|
2
|
+
import type { RegistryUserRole } from '@handoff/registry/auth';
|
|
3
|
+
|
|
4
|
+
declare module 'next-auth' {
|
|
5
|
+
interface Session {
|
|
6
|
+
user?: DefaultSession['user'] & {
|
|
7
|
+
id: string;
|
|
8
|
+
role: RegistryUserRole;
|
|
9
|
+
authVersion: number;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface User {
|
|
14
|
+
role: RegistryUserRole;
|
|
15
|
+
status: string;
|
|
16
|
+
authVersion: number;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module 'next-auth/jwt' {
|
|
21
|
+
interface JWT {
|
|
22
|
+
userId?: string;
|
|
23
|
+
role?: RegistryUserRole;
|
|
24
|
+
authVersion?: number;
|
|
25
|
+
authInvalid?: boolean;
|
|
26
|
+
}
|
|
27
|
+
}
|