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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-app",
|
|
3
|
-
"version": "2.0.0-
|
|
3
|
+
"version": "2.0.0-3",
|
|
4
4
|
"description": "Automated documentation toolchain for building client side documentation from figma",
|
|
5
5
|
"author ": {
|
|
6
6
|
"name": "Convertiv",
|
|
@@ -90,9 +90,6 @@
|
|
|
90
90
|
"dotenv": "^16.0.3",
|
|
91
91
|
"drizzle-orm": "^0.45.2",
|
|
92
92
|
"esbuild": "^0.25.0",
|
|
93
|
-
"eslint": "^8.23.1",
|
|
94
|
-
"eslint-config-next": "^15.3.4",
|
|
95
|
-
"eslint-config-prettier": "^10.0.2",
|
|
96
93
|
"fs-extra": "^11.3.0",
|
|
97
94
|
"gray-matter": "^4.0.3",
|
|
98
95
|
"handlebars": "^4.7.8",
|
|
@@ -102,6 +99,7 @@
|
|
|
102
99
|
"lodash": "^4.17.21",
|
|
103
100
|
"lucide-react": "^0.518.0",
|
|
104
101
|
"next": "^15.3.4",
|
|
102
|
+
"next-auth": "^4.24.15",
|
|
105
103
|
"next-themes": "^0.4.4",
|
|
106
104
|
"node-html-parser": "^6.1.4",
|
|
107
105
|
"pg": "^8.22.0",
|
|
@@ -137,6 +135,9 @@
|
|
|
137
135
|
"@types/pg": "^8.20.0",
|
|
138
136
|
"@types/yargs": "^17.0.33",
|
|
139
137
|
"drizzle-kit": "^0.31.10",
|
|
138
|
+
"eslint": "^8.23.1",
|
|
139
|
+
"eslint-config-next": "^15.3.4",
|
|
140
|
+
"eslint-config-prettier": "^10.0.2",
|
|
140
141
|
"np": "^10.2.0",
|
|
141
142
|
"prettier-plugin-tailwindcss": "^0.7.0",
|
|
142
143
|
"sass-embedded": "^1.89.0",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useSession } from 'next-auth/react';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { useRouter } from 'next/router';
|
|
4
|
+
import { useEffect, type ReactNode } from 'react';
|
|
5
|
+
import { cn } from '../../lib/utils';
|
|
6
|
+
import { AuthShell } from './AuthShell';
|
|
7
|
+
|
|
8
|
+
const navGroups = [
|
|
9
|
+
{
|
|
10
|
+
label: 'Account',
|
|
11
|
+
links: [
|
|
12
|
+
{ href: '/account', label: 'Profile' },
|
|
13
|
+
{ href: '/account/tokens', label: 'Access tokens' },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
label: 'Administration',
|
|
18
|
+
links: [{ href: '/account/users', label: 'Users', adminOnly: true }],
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function AccountLayout({ children, title }: { children: ReactNode; title: string }) {
|
|
23
|
+
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
24
|
+
return <AuthShell title={title}>{null}</AuthShell>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return <RegistryAccountLayout title={title}>{children}</RegistryAccountLayout>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function RegistryAccountLayout({ children, title }: { children: ReactNode; title: string }) {
|
|
31
|
+
const { data: session, status } = useSession();
|
|
32
|
+
const router = useRouter();
|
|
33
|
+
const role = (session?.user as { role?: string } | undefined)?.role;
|
|
34
|
+
|
|
35
|
+
// Redirect to login whenever there's no live user, not just on `unauthenticated`. An invalidated
|
|
36
|
+
// token still reports `authenticated` but carries no user, so checking status alone leaves the
|
|
37
|
+
// shell stuck in a broken half-signed-in state.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (status !== 'loading' && !session?.user) {
|
|
40
|
+
void router.replace(`/login?callbackUrl=${encodeURIComponent(router.asPath)}`);
|
|
41
|
+
}
|
|
42
|
+
}, [router, session, status]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<AuthShell title={title} wide>
|
|
46
|
+
{status === 'loading' || !session?.user ? (
|
|
47
|
+
<p className="py-20 text-center text-sm text-muted-foreground">Loading account…</p>
|
|
48
|
+
) : (
|
|
49
|
+
<div className="grid gap-8 md:grid-cols-[200px_minmax(0,1fr)]">
|
|
50
|
+
<aside className="space-y-6">
|
|
51
|
+
{navGroups
|
|
52
|
+
.map((group) => ({
|
|
53
|
+
...group,
|
|
54
|
+
links: group.links.filter((link) => !('adminOnly' in link && link.adminOnly) || role === 'admin'),
|
|
55
|
+
}))
|
|
56
|
+
.filter((group) => group.links.length > 0)
|
|
57
|
+
.map((group) => (
|
|
58
|
+
<div key={group.label}>
|
|
59
|
+
<p className="mb-3 px-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">{group.label}</p>
|
|
60
|
+
<nav className="flex gap-1 overflow-x-auto md:flex-col">
|
|
61
|
+
{group.links.map((link) => {
|
|
62
|
+
const active = router.pathname === link.href;
|
|
63
|
+
return (
|
|
64
|
+
<Link
|
|
65
|
+
key={link.href}
|
|
66
|
+
href={link.href}
|
|
67
|
+
className={cn(
|
|
68
|
+
'whitespace-nowrap rounded-md px-3 py-2 text-sm',
|
|
69
|
+
active
|
|
70
|
+
? 'bg-accent font-medium text-accent-foreground'
|
|
71
|
+
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{link.label}
|
|
75
|
+
</Link>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
</nav>
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
</aside>
|
|
82
|
+
<section className="min-w-0">{children}</section>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</AuthShell>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { LogOut, UserRound } from 'lucide-react';
|
|
2
|
+
import { signOut, useSession } from 'next-auth/react';
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useRouter } from 'next/router';
|
|
5
|
+
import { Badge } from '../ui/badge';
|
|
6
|
+
import { Button } from '../ui/button';
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuLabel,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from '../ui/dropdown-menu';
|
|
15
|
+
|
|
16
|
+
const isRegistryRuntime = process.env.HANDOFF_RUNTIME_MODE === 'registry';
|
|
17
|
+
|
|
18
|
+
export function AuthControls({ mobile = false }: { mobile?: boolean }) {
|
|
19
|
+
if (!isRegistryRuntime) return null;
|
|
20
|
+
return <RegistryAuthControls mobile={mobile} />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function RegistryAuthControls({ mobile }: { mobile: boolean }) {
|
|
24
|
+
const { data: session, status } = useSession();
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
|
|
27
|
+
if (status === 'loading')
|
|
28
|
+
return <div className={mobile ? 'h-9 w-full animate-pulse rounded-md bg-muted' : 'h-8 w-20 animate-pulse rounded-md bg-muted'} />;
|
|
29
|
+
|
|
30
|
+
if (!session?.user) {
|
|
31
|
+
return (
|
|
32
|
+
<Button asChild variant="outline" size="sm" className={mobile ? 'w-full' : undefined}>
|
|
33
|
+
<Link href="/login">Sign in</Link>
|
|
34
|
+
</Button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const user = session.user as typeof session.user & { role?: string };
|
|
39
|
+
const displayName = user.name || user.email || 'Account';
|
|
40
|
+
|
|
41
|
+
if (mobile) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-2 border-t pt-4">
|
|
44
|
+
<Link href="/account" className="flex items-center gap-2 rounded-md px-4 py-2 text-sm hover:bg-accent/50">
|
|
45
|
+
<UserRound className="h-4 w-4" />
|
|
46
|
+
<span className="truncate">{displayName}</span>
|
|
47
|
+
</Link>
|
|
48
|
+
<Button
|
|
49
|
+
variant="ghost"
|
|
50
|
+
className="w-full justify-start font-normal"
|
|
51
|
+
onClick={() => void signOut({ callbackUrl: `${process.env.HANDOFF_APP_BASE_PATH ?? ''}/login` })}
|
|
52
|
+
>
|
|
53
|
+
<LogOut className="mr-2 h-4 w-4" />
|
|
54
|
+
Sign out
|
|
55
|
+
</Button>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<DropdownMenu>
|
|
62
|
+
<DropdownMenuTrigger asChild>
|
|
63
|
+
<Button variant="outline" size="sm" className="max-w-48 gap-2">
|
|
64
|
+
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px]">
|
|
65
|
+
{displayName.slice(0, 1).toUpperCase()}
|
|
66
|
+
</span>
|
|
67
|
+
<span className="truncate">{displayName}</span>
|
|
68
|
+
</Button>
|
|
69
|
+
</DropdownMenuTrigger>
|
|
70
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
71
|
+
<DropdownMenuLabel className="space-y-1 font-normal">
|
|
72
|
+
<p className="truncate text-sm font-medium">{displayName}</p>
|
|
73
|
+
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
|
74
|
+
{user.role ? (
|
|
75
|
+
<Badge className="mt-1" variant="secondary">
|
|
76
|
+
{user.role}
|
|
77
|
+
</Badge>
|
|
78
|
+
) : null}
|
|
79
|
+
</DropdownMenuLabel>
|
|
80
|
+
<DropdownMenuSeparator />
|
|
81
|
+
<DropdownMenuItem asChild>
|
|
82
|
+
<Link href="/account">
|
|
83
|
+
<UserRound />
|
|
84
|
+
Account settings
|
|
85
|
+
</Link>
|
|
86
|
+
</DropdownMenuItem>
|
|
87
|
+
<DropdownMenuItem
|
|
88
|
+
onSelect={(event) => {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
void signOut({ redirect: false }).then(() => void router.push('/login'));
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<LogOut />
|
|
94
|
+
Sign out
|
|
95
|
+
</DropdownMenuItem>
|
|
96
|
+
</DropdownMenuContent>
|
|
97
|
+
</DropdownMenu>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ArrowLeft } from 'lucide-react';
|
|
2
|
+
import Head from 'next/head';
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { Badge } from '../ui/badge';
|
|
6
|
+
import { Button } from '../ui/button';
|
|
7
|
+
import { ThemeProvider } from '../util/theme-provider';
|
|
8
|
+
|
|
9
|
+
const isRegistryRuntime = process.env.HANDOFF_RUNTIME_MODE === 'registry';
|
|
10
|
+
|
|
11
|
+
interface AuthShellProps {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
wide?: boolean;
|
|
16
|
+
hideNav?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AuthShell({ children, title, description, wide = false, hideNav = false }: AuthShellProps) {
|
|
20
|
+
if (!isRegistryRuntime) {
|
|
21
|
+
return (
|
|
22
|
+
<main className="flex min-h-screen items-center justify-center px-6">
|
|
23
|
+
<div className="max-w-md text-center">
|
|
24
|
+
<h1 className="text-2xl font-semibold">Registry only</h1>
|
|
25
|
+
<p className="mt-2 text-sm text-muted-foreground">Account management is available only in a deployed registry.</p>
|
|
26
|
+
<Button asChild variant="ghost" size="sm" className="mt-6 gap-2 text-muted-foreground hover:text-foreground">
|
|
27
|
+
<Link href="/">
|
|
28
|
+
<ArrowLeft className="h-4 w-4" />
|
|
29
|
+
Back to documentation
|
|
30
|
+
</Link>
|
|
31
|
+
</Button>
|
|
32
|
+
</div>
|
|
33
|
+
</main>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
|
39
|
+
<Head>
|
|
40
|
+
<title>{title} · Handoff Registry</title>
|
|
41
|
+
{description ? <meta name="description" content={description} /> : null}
|
|
42
|
+
</Head>
|
|
43
|
+
<div className="min-h-screen bg-background">
|
|
44
|
+
<header className="border-b">
|
|
45
|
+
<div className="container mx-auto flex h-16 max-w-[1200px] items-center justify-between px-6">
|
|
46
|
+
{hideNav ? (
|
|
47
|
+
<span className="flex items-center gap-3">
|
|
48
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
49
|
+
<img className="max-h-5" src={`${process.env.HANDOFF_APP_BASE_PATH ?? ''}/logo.svg`} alt="Handoff" />
|
|
50
|
+
<Badge variant="info">Registry</Badge>
|
|
51
|
+
</span>
|
|
52
|
+
) : (
|
|
53
|
+
<Link href="/" className="flex items-center gap-3">
|
|
54
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
55
|
+
<img className="max-h-5" src={`${process.env.HANDOFF_APP_BASE_PATH ?? ''}/logo.svg`} alt="Handoff" />
|
|
56
|
+
<Badge variant="info">Registry</Badge>
|
|
57
|
+
</Link>
|
|
58
|
+
)}
|
|
59
|
+
{hideNav ? null : (
|
|
60
|
+
<Button asChild variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
|
|
61
|
+
<Link href="/">
|
|
62
|
+
<ArrowLeft className="h-4 w-4" />
|
|
63
|
+
Back to documentation
|
|
64
|
+
</Link>
|
|
65
|
+
</Button>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</header>
|
|
69
|
+
<main className={`container mx-auto px-6 py-10 ${wide ? 'max-w-[1200px]' : 'max-w-xl'}`}>{children}</main>
|
|
70
|
+
</div>
|
|
71
|
+
</ThemeProvider>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useRouter } from 'next/router';
|
|
2
|
+
import { useEffect, type ReactNode } from 'react';
|
|
3
|
+
import { authApiUrl } from './api';
|
|
4
|
+
|
|
5
|
+
const isRegistryRuntime = process.env.HANDOFF_RUNTIME_MODE === 'registry';
|
|
6
|
+
|
|
7
|
+
/** Keep every registry browser page on the one-time installer until setup is complete. */
|
|
8
|
+
export function RegistryInstallGate({ children }: { children: ReactNode }) {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!isRegistryRuntime || !router.isReady || router.pathname === '/install') return;
|
|
13
|
+
void fetch(authApiUrl('/api/install'), { credentials: 'include', cache: 'no-store' })
|
|
14
|
+
.then(async (response) => {
|
|
15
|
+
if (!response.ok) return;
|
|
16
|
+
const status = (await response.json()) as { installed?: boolean };
|
|
17
|
+
if (status.installed === false) await router.replace('/install');
|
|
18
|
+
})
|
|
19
|
+
.catch(() => undefined);
|
|
20
|
+
}, [router]);
|
|
21
|
+
|
|
22
|
+
return <>{children}</>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SessionProvider } from 'next-auth/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
const isRegistryRuntime = process.env.HANDOFF_RUNTIME_MODE === 'registry';
|
|
5
|
+
const authBasePath = `${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/auth`;
|
|
6
|
+
|
|
7
|
+
export function RegistrySessionProvider({ children }: { children: ReactNode }) {
|
|
8
|
+
if (!isRegistryRuntime) return <>{children}</>;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<SessionProvider basePath={authBasePath} refetchOnWindowFocus refetchInterval={5 * 60}>
|
|
12
|
+
{children}
|
|
13
|
+
</SessionProvider>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
|
|
2
|
+
|
|
3
|
+
export const authApiUrl = (path: string): string => {
|
|
4
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
5
|
+
return `${basePath}${normalizedPath}`;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const authPageUrl = (path: string): string => {
|
|
9
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
10
|
+
return `${basePath}${normalizedPath}`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const readApiError = async (response: Response, fallback: string): Promise<string> => {
|
|
14
|
+
const body = (await response.json().catch(() => null)) as { error?: string | { message?: string }; message?: string } | null;
|
|
15
|
+
|
|
16
|
+
if (typeof body?.error === 'string') return body.error;
|
|
17
|
+
if (typeof body?.error === 'object' && body.error?.message) return body.error.message;
|
|
18
|
+
if (body?.message) return body.message;
|
|
19
|
+
return fallback;
|
|
20
|
+
};
|
|
@@ -6,6 +6,7 @@ import { MobileNav } from '../../components/Navigation/MobileNav';
|
|
|
6
6
|
import { cn } from '../../lib/utils';
|
|
7
7
|
import { Badge } from '../ui/badge';
|
|
8
8
|
import { useConfigContext } from '../context/ConfigContext';
|
|
9
|
+
import { AuthControls } from '../Auth/AuthControls';
|
|
9
10
|
|
|
10
11
|
const RUNTIME_MODE_LABEL: Record<'workspace' | 'registry', string> = {
|
|
11
12
|
workspace: 'Workspace',
|
|
@@ -51,6 +52,7 @@ export function Header() {
|
|
|
51
52
|
</div>
|
|
52
53
|
<div className="hidden items-center gap-4 @2xl:flex">
|
|
53
54
|
<MainNav />
|
|
55
|
+
{runtimeMode === 'registry' ? <AuthControls /> : null}
|
|
54
56
|
<ModeToggle />
|
|
55
57
|
</div>
|
|
56
58
|
<div className="flex items-center gap-4 @2xl:hidden">
|
|
@@ -8,6 +8,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../.
|
|
|
8
8
|
import { cn } from '../../lib/utils';
|
|
9
9
|
import { useConfigContext } from '../context/ConfigContext';
|
|
10
10
|
import { useNav } from '../context/NavProvider';
|
|
11
|
+
import { AuthControls } from '../Auth/AuthControls';
|
|
11
12
|
|
|
12
13
|
const trimSlashes = (input: string): string => {
|
|
13
14
|
return input.replace(/^\/+|\/+$/g, '');
|
|
@@ -90,6 +91,7 @@ export function MobileNav() {
|
|
|
90
91
|
)}
|
|
91
92
|
</Button>
|
|
92
93
|
</div>
|
|
94
|
+
{context.config?.runtime?.mode === 'registry' ? <AuthControls mobile /> : null}
|
|
93
95
|
</div>
|
|
94
96
|
</SheetContent>
|
|
95
97
|
</Sheet>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import type { RegistryDatabase } from '@handoff/registry/db/client';
|
|
3
|
+
import { getRegistryInstallationState, type RegistryUser } from '@handoff/registry/auth';
|
|
4
|
+
import { getServerRuntimeConfig } from '../docs-api/runtime-config';
|
|
5
|
+
import { getRegistryConnection, RegistryConnectionError } from '../registry-connection';
|
|
6
|
+
import { canonicalRegistryUrl, getRegistrySessionUser } from './config';
|
|
7
|
+
|
|
8
|
+
export const allowApiMethods = (req: NextApiRequest, res: NextApiResponse, methods: string[]): string | null => {
|
|
9
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
10
|
+
if (methods.includes(method)) return method;
|
|
11
|
+
res.setHeader('Allow', methods.join(', '));
|
|
12
|
+
res.status(405).json({ error: `Method ${method} is not allowed.` });
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const registryPageUrl = (
|
|
17
|
+
path: string,
|
|
18
|
+
query?: Record<string, string>,
|
|
19
|
+
fragment?: Record<string, string>
|
|
20
|
+
): string | null => {
|
|
21
|
+
const canonical = canonicalRegistryUrl();
|
|
22
|
+
if (!canonical) return null;
|
|
23
|
+
const base = canonical.pathname.replace(/\/+$/, '');
|
|
24
|
+
canonical.pathname = `${base}/${path.replace(/^\/+/, '')}`;
|
|
25
|
+
canonical.search = '';
|
|
26
|
+
canonical.hash = '';
|
|
27
|
+
if (query) {
|
|
28
|
+
for (const [key, value] of Object.entries(query)) canonical.searchParams.set(key, value);
|
|
29
|
+
}
|
|
30
|
+
// Put secrets (invite/reset tokens) in the fragment rather than the query string. The fragment
|
|
31
|
+
// never reaches the server, so it stays out of access logs, proxies, and Referer headers.
|
|
32
|
+
if (fragment) {
|
|
33
|
+
const params = new URLSearchParams();
|
|
34
|
+
for (const [key, value] of Object.entries(fragment)) params.set(key, value);
|
|
35
|
+
canonical.hash = params.toString();
|
|
36
|
+
}
|
|
37
|
+
return canonical.toString();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const requestOriginIsAllowed = (req: NextApiRequest): boolean => {
|
|
41
|
+
const origin = req.headers.origin;
|
|
42
|
+
if (!origin) return true;
|
|
43
|
+
const canonical = canonicalRegistryUrl();
|
|
44
|
+
if (canonical) return origin === canonical.origin;
|
|
45
|
+
const proto = String(req.headers['x-forwarded-proto'] || 'http')
|
|
46
|
+
.split(',')[0]
|
|
47
|
+
.trim();
|
|
48
|
+
return origin === `${proto}://${req.headers.host}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface RegistryApiContext {
|
|
52
|
+
db: RegistryDatabase;
|
|
53
|
+
user?: RegistryUser;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RegistryApiOptions {
|
|
57
|
+
installed?: boolean;
|
|
58
|
+
auth?: 'user' | 'admin';
|
|
59
|
+
mutation?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Shared browser-auth API boundary. Client redirects are convenience only; installation, live user
|
|
64
|
+
* status, role, and same-origin mutation checks are enforced here on every protected request.
|
|
65
|
+
*/
|
|
66
|
+
export const prepareRegistryApi = async (
|
|
67
|
+
req: NextApiRequest,
|
|
68
|
+
res: NextApiResponse,
|
|
69
|
+
options: RegistryApiOptions = {}
|
|
70
|
+
): Promise<RegistryApiContext | null> => {
|
|
71
|
+
if (getServerRuntimeConfig().mode !== 'registry') {
|
|
72
|
+
res.status(409).json({ error: 'This endpoint is available only in registry mode.' });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (options.mutation && !requestOriginIsAllowed(req)) {
|
|
76
|
+
res.status(403).json({ error: 'The request origin is not allowed.' });
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let db: RegistryDatabase;
|
|
81
|
+
try {
|
|
82
|
+
({ db } = await getRegistryConnection());
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = error instanceof RegistryConnectionError ? error.message : 'The registry database is unavailable.';
|
|
85
|
+
res.status(503).json({ error: message });
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.installed !== false) {
|
|
90
|
+
try {
|
|
91
|
+
const installation = await getRegistryInstallationState(db);
|
|
92
|
+
if (installation.status !== 'installed') {
|
|
93
|
+
res.status(409).json({ error: 'Complete the registry installation before using this endpoint.', code: 'installation_required' });
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
res.status(503).json({ error: 'Registry authentication migrations are missing. Run `handoff-app db:migrate`.' });
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!options.auth) return { db };
|
|
103
|
+
const user = await getRegistrySessionUser(req, res);
|
|
104
|
+
if (!user) {
|
|
105
|
+
res.status(401).json({ error: 'Sign in with an active registry account.' });
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (options.auth === 'admin' && user.role !== 'admin') {
|
|
109
|
+
res.status(403).json({ error: 'Administrator access is required.' });
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return { db, user };
|
|
113
|
+
};
|