create-handover 0.1.1
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 +61 -0
- package/index.mjs +243 -0
- package/package.json +31 -0
- package/template/README.md +61 -0
- package/template/app/admin/login/page.tsx +261 -0
- package/template/app/admin/page.tsx +1346 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +118 -0
- package/template/app/layout.tsx +36 -0
- package/template/app/page.tsx +78 -0
- package/template/components/HandoverProvider.tsx +128 -0
- package/template/components/Modal.tsx +75 -0
- package/template/eslint.config.mjs +18 -0
- package/template/lib/branding.ts +12 -0
- package/template/lib/handover.integration.test.ts +322 -0
- package/template/lib/handover.ts +389 -0
- package/template/next.config.ts +16 -0
- package/template/package.json +29 -0
- package/template/pnpm-workspace.yaml +3 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/tsconfig.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# create-handover
|
|
2
|
+
|
|
3
|
+
Scaffold a new [Handover](https://handover.carney.dev) client site — a white-label admin boilerplate that connects to the Handover CMS platform via API key.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm create handoverhq@latest
|
|
9
|
+
# or
|
|
10
|
+
npx create-handover@latest
|
|
11
|
+
# or with a target directory:
|
|
12
|
+
npx create-handover@latest my-client-site
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The CLI will prompt you for:
|
|
16
|
+
- **Project directory name** — where to scaffold the project
|
|
17
|
+
- **tRPC placeholder** — optional tRPC env stub (y/N)
|
|
18
|
+
- **Theme mode** — light or dark default
|
|
19
|
+
|
|
20
|
+
## What you get
|
|
21
|
+
|
|
22
|
+
A production-ready Next.js admin template pre-wired to the Handover SDK:
|
|
23
|
+
|
|
24
|
+
- `/admin` — password-protected content & media management dashboard
|
|
25
|
+
- `/admin/login` — authentication gate
|
|
26
|
+
- White-label branding surface via `lib/branding.ts` (name, logo, colors, copy)
|
|
27
|
+
- Storage-aware image upload with progress feedback
|
|
28
|
+
- Lock-state (`HANDOVER_LOCKED`) handling built in
|
|
29
|
+
|
|
30
|
+
## Setup after scaffolding
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cd my-client-site
|
|
34
|
+
pnpm install
|
|
35
|
+
cp .env.example .env.local
|
|
36
|
+
# Fill in your API key and URL from the Handover dashboard
|
|
37
|
+
pnpm dev
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Environment variables
|
|
41
|
+
|
|
42
|
+
| Variable | Description |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `NEXT_PUBLIC_HANDOVER_API_URL` | Your Handover platform URL |
|
|
45
|
+
| `NEXT_PUBLIC_HANDOVER_API_KEY` | Project API key (`ho_live_*`) from the dashboard |
|
|
46
|
+
| `NEXT_PUBLIC_SITE_NAME` | Display name for the admin UI (optional) |
|
|
47
|
+
|
|
48
|
+
## Options
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
--template-dir <path> Use a custom local template instead of the bundled one
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
HANDOVER_TEMPLATE_DIR=/path/to/template Env-based template override
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Links
|
|
59
|
+
|
|
60
|
+
- [Handover Platform](https://handover.carney.dev)
|
|
61
|
+
- [SDK on npm](https://www.npmjs.com/package/@handoverhq/sdk)
|
package/index.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import readline from "node:readline/promises";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
// Template is bundled inside this package, relative to this file.
|
|
13
|
+
// Override with --template-dir or HANDOVER_TEMPLATE_DIR env var.
|
|
14
|
+
const defaultTemplateDir = path.join(__dirname, "template");
|
|
15
|
+
|
|
16
|
+
const EXCLUDED_ENTRIES = new Set([
|
|
17
|
+
".next",
|
|
18
|
+
"node_modules",
|
|
19
|
+
".env.local",
|
|
20
|
+
"pnpm-lock.yaml",
|
|
21
|
+
"pnpm-workspace.yaml",
|
|
22
|
+
"tsconfig.tsbuildinfo",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function printUsage() {
|
|
26
|
+
console.log("Usage: pnpm create handoverhq@latest [target-directory]");
|
|
27
|
+
console.log(" npx create-handover@latest [target-directory]");
|
|
28
|
+
console.log("");
|
|
29
|
+
console.log("Options:");
|
|
30
|
+
console.log(" --template-dir <path> Use a custom template directory");
|
|
31
|
+
console.log(" -h, --help Show this help message");
|
|
32
|
+
console.log("");
|
|
33
|
+
console.log("Env override: HANDOVER_TEMPLATE_DIR=/abs/path/to/template");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveInputPath(rawPath) {
|
|
37
|
+
if (!rawPath) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (path.isAbsolute(rawPath)) {
|
|
42
|
+
return rawPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return path.resolve(process.cwd(), rawPath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const args = argv.slice(2);
|
|
50
|
+
let targetArg;
|
|
51
|
+
let templateDirArg;
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
|
|
56
|
+
if (arg === "--") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (arg === "-h" || arg === "--help") {
|
|
61
|
+
return { showHelp: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (arg === "--template-dir") {
|
|
65
|
+
const value = args[i + 1];
|
|
66
|
+
if (!value || value.startsWith("-")) {
|
|
67
|
+
throw new Error("Missing value for --template-dir");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
templateDirArg = value;
|
|
71
|
+
i += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (arg.startsWith("-")) {
|
|
76
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!targetArg) {
|
|
80
|
+
targetArg = arg;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw new Error(`Unexpected extra argument: ${arg}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
showHelp: false,
|
|
89
|
+
targetArg,
|
|
90
|
+
templateDirArg,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toPackageName(raw) {
|
|
95
|
+
const normalized = raw
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9-_]/g, "-")
|
|
98
|
+
.replace(/--+/g, "-")
|
|
99
|
+
.replace(/^-+|-+$/g, "");
|
|
100
|
+
|
|
101
|
+
return normalized || "handover-client-site";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function exists(filePath) {
|
|
105
|
+
try {
|
|
106
|
+
await fs.access(filePath);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function ensureEmptyDir(targetDir) {
|
|
114
|
+
if (!(await exists(targetDir))) {
|
|
115
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const contents = await fs.readdir(targetDir);
|
|
120
|
+
if (contents.length > 0) {
|
|
121
|
+
throw new Error(`Target directory is not empty: ${targetDir}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function copyDirectory(srcDir, dstDir) {
|
|
126
|
+
await fs.mkdir(dstDir, { recursive: true });
|
|
127
|
+
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
|
128
|
+
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (EXCLUDED_ENTRIES.has(entry.name)) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sourcePath = path.join(srcDir, entry.name);
|
|
135
|
+
const targetPath = path.join(dstDir, entry.name);
|
|
136
|
+
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
await copyDirectory(sourcePath, targetPath);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (entry.isFile()) {
|
|
143
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function updateScaffoldPackageJson(targetDir) {
|
|
149
|
+
const packageJsonPath = path.join(targetDir, "package.json");
|
|
150
|
+
const packageJsonRaw = await fs.readFile(packageJsonPath, "utf8");
|
|
151
|
+
const packageJson = JSON.parse(packageJsonRaw);
|
|
152
|
+
|
|
153
|
+
packageJson.name = toPackageName(path.basename(targetDir));
|
|
154
|
+
packageJson.private = true;
|
|
155
|
+
|
|
156
|
+
await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function run() {
|
|
160
|
+
const { showHelp, targetArg, templateDirArg } = parseArgs(process.argv);
|
|
161
|
+
const templateDirInput =
|
|
162
|
+
templateDirArg || (process.env.HANDOVER_TEMPLATE_DIR || "").trim() || defaultTemplateDir;
|
|
163
|
+
const templateDir = resolveInputPath(templateDirInput);
|
|
164
|
+
|
|
165
|
+
if (showHelp) {
|
|
166
|
+
printUsage();
|
|
167
|
+
process.exit(0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
171
|
+
|
|
172
|
+
const projectInput =
|
|
173
|
+
targetArg ||
|
|
174
|
+
(await rl.question("Project directory name (e.g. my-client-site): ")).trim() ||
|
|
175
|
+
"my-client-site";
|
|
176
|
+
|
|
177
|
+
const trpcInput = (
|
|
178
|
+
await rl.question("Include tRPC placeholder setup? (y/N): ")
|
|
179
|
+
)
|
|
180
|
+
.trim()
|
|
181
|
+
.toLowerCase();
|
|
182
|
+
const includeTrpc = trpcInput === "y" || trpcInput === "yes";
|
|
183
|
+
|
|
184
|
+
const modeInput = (
|
|
185
|
+
await rl.question("Default theme mode? (light/dark) [light]: ")
|
|
186
|
+
)
|
|
187
|
+
.trim()
|
|
188
|
+
.toLowerCase();
|
|
189
|
+
const themeMode = modeInput === "dark" ? "dark" : "light";
|
|
190
|
+
|
|
191
|
+
rl.close();
|
|
192
|
+
|
|
193
|
+
const targetDir = path.resolve(process.cwd(), projectInput);
|
|
194
|
+
|
|
195
|
+
if (!(await exists(templateDir))) {
|
|
196
|
+
throw new Error(`Template directory not found: ${templateDir}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await ensureEmptyDir(targetDir);
|
|
200
|
+
await copyDirectory(templateDir, targetDir);
|
|
201
|
+
await updateScaffoldPackageJson(targetDir);
|
|
202
|
+
|
|
203
|
+
const envExamplePath = path.join(targetDir, ".env.example");
|
|
204
|
+
const readmePath = path.join(targetDir, "README.md");
|
|
205
|
+
const globalsPath = path.join(targetDir, "app", "globals.css");
|
|
206
|
+
|
|
207
|
+
if (themeMode === "dark") {
|
|
208
|
+
const globalsCss = await fs.readFile(globalsPath, "utf8");
|
|
209
|
+
const darkCss = globalsCss
|
|
210
|
+
.replace("--background: var(--surface-100);", "--background: var(--bg-900);")
|
|
211
|
+
.replace("--foreground: var(--text-900);", "--foreground: #e2e8f0;")
|
|
212
|
+
.replace("--surface-card-bg: #ffffff;", "--surface-card-bg: #0f172a;");
|
|
213
|
+
await fs.writeFile(globalsPath, darkCss, "utf8");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (includeTrpc) {
|
|
217
|
+
const envExample = await fs.readFile(envExamplePath, "utf8");
|
|
218
|
+
const withTrpcEnv = `${envExample}\n# Optional tRPC API endpoint placeholder\nNEXT_PUBLIC_TRPC_URL=https://api.example.com/trpc\n`;
|
|
219
|
+
await fs.writeFile(envExamplePath, withTrpcEnv, "utf8");
|
|
220
|
+
|
|
221
|
+
const readme = await fs.readFile(readmePath, "utf8");
|
|
222
|
+
const trpcNote = "\n## Optional tRPC placeholder\n\nYou enabled the tRPC placeholder flag.\nSet `NEXT_PUBLIC_TRPC_URL` if your project exposes a tRPC endpoint.\nThis template does not ship runtime tRPC coupling by default.\n";
|
|
223
|
+
await fs.writeFile(readmePath, `${readme}${trpcNote}`, "utf8");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const relativePath = path.relative(process.cwd(), targetDir) || ".";
|
|
227
|
+
|
|
228
|
+
console.log("\n✅ Scaffold created successfully.");
|
|
229
|
+
console.log(`📁 Location: ${relativePath}`);
|
|
230
|
+
console.log(`🎨 Theme mode: ${themeMode}`);
|
|
231
|
+
console.log(`🔌 tRPC placeholder: ${includeTrpc ? "enabled" : "disabled"}`);
|
|
232
|
+
console.log("\nNext steps:");
|
|
233
|
+
console.log(` cd ${relativePath}`);
|
|
234
|
+
console.log(" pnpm install");
|
|
235
|
+
console.log(" cp .env.example .env.local");
|
|
236
|
+
console.log(" # Fill in your API key and URL from the Handover dashboard");
|
|
237
|
+
console.log(" pnpm dev");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
run().catch((error) => {
|
|
241
|
+
console.error(`\ncreate-handover failed: ${error.message}`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-handover",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Scaffold a new Handover client site — the white-label admin boilerplate for the Handover CMS platform",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-handover": "./index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.mjs",
|
|
11
|
+
"template",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"handover",
|
|
19
|
+
"cms",
|
|
20
|
+
"scaffold",
|
|
21
|
+
"create",
|
|
22
|
+
"boilerplate",
|
|
23
|
+
"nextjs",
|
|
24
|
+
"white-label"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/olivercarney/handover-platform"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Handover Boilerplate
|
|
2
|
+
|
|
3
|
+
Client-facing template for websites connected to Handover via SDK/API only.
|
|
4
|
+
|
|
5
|
+
## What this template includes
|
|
6
|
+
|
|
7
|
+
- Public site shell (`/`)
|
|
8
|
+
- Embedded client admin (`/admin/login`, `/admin`)
|
|
9
|
+
- Text and image editing through Handover API endpoints
|
|
10
|
+
- Lock-state handling (`HANDOVER_LOCKED`)
|
|
11
|
+
|
|
12
|
+
## Publish-ready acceptance criteria
|
|
13
|
+
|
|
14
|
+
- Client-facing landing page at `/` explains what the site is and links to `/admin/login`.
|
|
15
|
+
- Admin area has clear empty/loading/error states for text and image workflows.
|
|
16
|
+
- Branding surface is env-driven (`NEXT_PUBLIC_SITE_NAME`, `NEXT_PUBLIC_ADMIN_TITLE`, etc.).
|
|
17
|
+
- Runtime integration stays SDK/API-only (no Convex/Clerk/Polar coupling).
|
|
18
|
+
- Build and lint pass in scaffold output before client handoff.
|
|
19
|
+
|
|
20
|
+
## Required environment variables
|
|
21
|
+
|
|
22
|
+
Create `.env.local` (or copy from `.env.example`) with:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
NEXT_PUBLIC_HANDOVER_API_URL=https://your-platform-domain
|
|
26
|
+
NEXT_PUBLIC_HANDOVER_API_KEY=ho_live_xxxxxxxxxxxxxxxxxxxx
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Optional branding variables:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
NEXT_PUBLIC_SITE_NAME="Client Site"
|
|
33
|
+
NEXT_PUBLIC_ADMIN_TITLE="Client Admin"
|
|
34
|
+
NEXT_PUBLIC_BRAND_TAGLINE="Keep your website content fresh with a secure, lightweight admin experience."
|
|
35
|
+
NEXT_PUBLIC_SUPPORT_EMAIL="support@example.com"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pnpm install
|
|
42
|
+
pnpm dev
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Open:
|
|
46
|
+
|
|
47
|
+
- `http://localhost:3000` for the public view
|
|
48
|
+
- `http://localhost:3000/admin/login` for client editing access
|
|
49
|
+
|
|
50
|
+
## Production handoff checklist
|
|
51
|
+
|
|
52
|
+
1. Set `NEXT_PUBLIC_HANDOVER_API_URL` and `NEXT_PUBLIC_HANDOVER_API_KEY`.
|
|
53
|
+
2. Confirm lock-state behavior by toggling lock in platform dashboard.
|
|
54
|
+
3. Upload and delete at least one image in `/admin`.
|
|
55
|
+
4. Update branding env vars to client-facing values.
|
|
56
|
+
5. Deploy.
|
|
57
|
+
|
|
58
|
+
## Boundary note
|
|
59
|
+
|
|
60
|
+
This boilerplate intentionally has no direct Convex/Clerk/Polar runtime dependency.
|
|
61
|
+
Integration boundary stays at Handover SDK/API.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, FormEvent } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useHandover } from "@/components/HandoverProvider";
|
|
6
|
+
import { Lock, UserPlus } from "lucide-react";
|
|
7
|
+
import { HandoverError } from "@/lib/handover";
|
|
8
|
+
import { branding } from "@/lib/branding";
|
|
9
|
+
|
|
10
|
+
export default function AdminLoginPage() {
|
|
11
|
+
const [username, setUsername] = useState("");
|
|
12
|
+
const [password, setPassword] = useState("");
|
|
13
|
+
const [clientPassword, setClientPassword] = useState("");
|
|
14
|
+
const [resetToken, setResetToken] = useState("");
|
|
15
|
+
const [resetPassword, setResetPassword] = useState("");
|
|
16
|
+
const [confirmResetPassword, setConfirmResetPassword] = useState("");
|
|
17
|
+
const [isResetMode, setIsResetMode] = useState(false);
|
|
18
|
+
const [needsBootstrap, setNeedsBootstrap] = useState(false);
|
|
19
|
+
const [error, setError] = useState("");
|
|
20
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
21
|
+
const { handover } = useHandover();
|
|
22
|
+
const router = useRouter();
|
|
23
|
+
|
|
24
|
+
const persistSession = (token: string, user: { id: string; username: string; role: string; status: string }) => {
|
|
25
|
+
localStorage.setItem("handover_admin_session", token);
|
|
26
|
+
localStorage.setItem("handover_admin_user", JSON.stringify(user));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
setIsLoading(true);
|
|
32
|
+
setError("");
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (isResetMode) {
|
|
36
|
+
if (resetPassword !== confirmResetPassword) {
|
|
37
|
+
setError("Reset passwords do not match.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await handover.resetAdminPasswordWithToken(resetToken, resetPassword);
|
|
41
|
+
setError("Password reset successful. You can now sign in.");
|
|
42
|
+
setIsResetMode(false);
|
|
43
|
+
setResetToken("");
|
|
44
|
+
setResetPassword("");
|
|
45
|
+
setConfirmResetPassword("");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (needsBootstrap) {
|
|
50
|
+
const result = await handover.bootstrapAdmin(clientPassword, username, password);
|
|
51
|
+
persistSession(result.token, result.user);
|
|
52
|
+
router.push("/admin");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = await handover.loginAdmin(username, password);
|
|
57
|
+
persistSession(result.token, result.user);
|
|
58
|
+
router.push("/admin");
|
|
59
|
+
} catch (err: unknown) {
|
|
60
|
+
if (err instanceof HandoverError && err.code === "AUTH_RATE_LIMITED") {
|
|
61
|
+
setError("Too many failed attempts. Please wait and try again.");
|
|
62
|
+
} else if (err instanceof HandoverError && err.code === "RESET_TOKEN_EXPIRED") {
|
|
63
|
+
setError("This reset token has expired. Request a new one from an admin.");
|
|
64
|
+
} else if (err instanceof HandoverError && err.code === "RESET_TOKEN_USED") {
|
|
65
|
+
setError("This reset token has already been used. Request a new one.");
|
|
66
|
+
} else if (err instanceof HandoverError && err.code === "INVALID_RESET_TOKEN") {
|
|
67
|
+
setError("Invalid reset token. Check the token and try again.");
|
|
68
|
+
} else if (err instanceof HandoverError && err.code === "ADMIN_NOT_BOOTSTRAPPED") {
|
|
69
|
+
setNeedsBootstrap(true);
|
|
70
|
+
setError("No admin users are set up yet. Create the owner account below.");
|
|
71
|
+
} else if (err instanceof HandoverError && err.code === "INVALID_CREDENTIALS") {
|
|
72
|
+
setError("Invalid username or password.");
|
|
73
|
+
} else if (err instanceof HandoverError && err.code === "ACCOUNT_DISABLED") {
|
|
74
|
+
setError("This account is disabled. Contact the project owner.");
|
|
75
|
+
} else if (err instanceof HandoverError && err.code === "INVALID_PASSWORD") {
|
|
76
|
+
setError("Invalid client password. Please try again.");
|
|
77
|
+
} else if (err instanceof HandoverError && err.code === "ADMIN_USERS_ALREADY_INITIALIZED") {
|
|
78
|
+
setNeedsBootstrap(false);
|
|
79
|
+
setError("Admin users are already initialized. Sign in with username and password.");
|
|
80
|
+
} else {
|
|
81
|
+
setError("Unable to sign in right now. Please try again in a moment.");
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
setIsLoading(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="min-h-screen admin-shell flex items-center justify-center p-[var(--spacing-page-x)] sm:p-[var(--spacing-card)]">
|
|
90
|
+
<div className="surface-card max-w-md w-full p-[var(--spacing-card)] sm:p-[var(--spacing-section)]">
|
|
91
|
+
<div className="text-center mb-[var(--spacing-section)]">
|
|
92
|
+
<div className="bg-teal-50 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-[var(--spacing-stack)]">
|
|
93
|
+
{needsBootstrap ? <UserPlus className="w-6 h-6 text-teal-700" /> : <Lock className="w-6 h-6 text-teal-700" />}
|
|
94
|
+
</div>
|
|
95
|
+
<p className="section-label">{branding.adminTitle}</p>
|
|
96
|
+
<h1 className="text-2xl font-bold text-slate-900 mt-[var(--spacing-inline)]">{isResetMode ? "Reset Admin Password" : needsBootstrap ? "Initialize Admin Access" : "Secure Admin Login"}</h1>
|
|
97
|
+
<p className="text-slate-600 mt-[var(--spacing-inline)]">
|
|
98
|
+
{isResetMode
|
|
99
|
+
? "Use your one-time reset token to set a new password."
|
|
100
|
+
: needsBootstrap
|
|
101
|
+
? "Create the owner account for this /admin workspace."
|
|
102
|
+
: "Sign in with your admin username and password."}
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<form onSubmit={handleSubmit} className="space-y-[var(--spacing-stack)]">
|
|
107
|
+
{!isResetMode && (
|
|
108
|
+
<div>
|
|
109
|
+
<label htmlFor="admin-username" className="mb-[var(--spacing-1)] block text-xs font-semibold uppercase tracking-wider text-slate-500">Username</label>
|
|
110
|
+
<input
|
|
111
|
+
id="admin-username"
|
|
112
|
+
type="text"
|
|
113
|
+
value={username}
|
|
114
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
115
|
+
placeholder="Username"
|
|
116
|
+
className="w-full px-[var(--spacing-stack)] py-2.5 border border-slate-200 rounded-lg focus-ring focus:border-transparent transition-all"
|
|
117
|
+
autoFocus
|
|
118
|
+
autoComplete="username"
|
|
119
|
+
required
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{!isResetMode && (
|
|
125
|
+
<div>
|
|
126
|
+
<label htmlFor="admin-password" className="mb-[var(--spacing-1)] block text-xs font-semibold uppercase tracking-wider text-slate-500">Password</label>
|
|
127
|
+
<input
|
|
128
|
+
id="admin-password"
|
|
129
|
+
type="password"
|
|
130
|
+
value={password}
|
|
131
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
132
|
+
placeholder={needsBootstrap ? "Owner account password" : "Password"}
|
|
133
|
+
className="w-full px-[var(--spacing-stack)] py-2.5 border border-slate-200 rounded-lg focus-ring focus:border-transparent transition-all"
|
|
134
|
+
autoComplete="current-password"
|
|
135
|
+
required
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{isResetMode && (
|
|
141
|
+
<>
|
|
142
|
+
<div>
|
|
143
|
+
<label htmlFor="admin-reset-token" className="mb-[var(--spacing-1)] block text-xs font-semibold uppercase tracking-wider text-slate-500">Reset Token</label>
|
|
144
|
+
<input
|
|
145
|
+
id="admin-reset-token"
|
|
146
|
+
type="text"
|
|
147
|
+
value={resetToken}
|
|
148
|
+
onChange={(e) => setResetToken(e.target.value)}
|
|
149
|
+
placeholder="One-time reset token"
|
|
150
|
+
className="w-full px-[var(--spacing-stack)] py-2.5 border border-slate-200 rounded-lg focus-ring focus:border-transparent transition-all"
|
|
151
|
+
autoFocus
|
|
152
|
+
required
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
<div>
|
|
156
|
+
<label htmlFor="admin-reset-password" className="mb-[var(--spacing-1)] block text-xs font-semibold uppercase tracking-wider text-slate-500">New Password</label>
|
|
157
|
+
<input
|
|
158
|
+
id="admin-reset-password"
|
|
159
|
+
type="password"
|
|
160
|
+
value={resetPassword}
|
|
161
|
+
onChange={(e) => setResetPassword(e.target.value)}
|
|
162
|
+
placeholder="New password"
|
|
163
|
+
className="w-full px-[var(--spacing-stack)] py-2.5 border border-slate-200 rounded-lg focus-ring focus:border-transparent transition-all"
|
|
164
|
+
required
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
<div>
|
|
168
|
+
<label htmlFor="admin-reset-confirm" className="mb-[var(--spacing-1)] block text-xs font-semibold uppercase tracking-wider text-slate-500">Confirm New Password</label>
|
|
169
|
+
<input
|
|
170
|
+
id="admin-reset-confirm"
|
|
171
|
+
type="password"
|
|
172
|
+
value={confirmResetPassword}
|
|
173
|
+
onChange={(e) => setConfirmResetPassword(e.target.value)}
|
|
174
|
+
placeholder="Confirm new password"
|
|
175
|
+
className="w-full px-[var(--spacing-stack)] py-2.5 border border-slate-200 rounded-lg focus-ring focus:border-transparent transition-all"
|
|
176
|
+
required
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
<p className="mt-[var(--spacing-inline)] text-xs text-slate-500">
|
|
180
|
+
Password must be 10-128 chars and include uppercase, lowercase, and a number.
|
|
181
|
+
</p>
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{needsBootstrap && (
|
|
186
|
+
<div>
|
|
187
|
+
<label htmlFor="admin-client-password" className="mb-[var(--spacing-1)] block text-xs font-semibold uppercase tracking-wider text-slate-500">Current Client Password</label>
|
|
188
|
+
<input
|
|
189
|
+
id="admin-client-password"
|
|
190
|
+
type="password"
|
|
191
|
+
value={clientPassword}
|
|
192
|
+
onChange={(e) => setClientPassword(e.target.value)}
|
|
193
|
+
placeholder="Current client password"
|
|
194
|
+
className="w-full px-[var(--spacing-stack)] py-2.5 border border-slate-200 rounded-lg focus-ring focus:border-transparent transition-all"
|
|
195
|
+
autoComplete="off"
|
|
196
|
+
required
|
|
197
|
+
/>
|
|
198
|
+
<p className="mt-[var(--spacing-inline)] text-xs text-slate-500">
|
|
199
|
+
Required once to authorize owner account setup.
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{error && (
|
|
205
|
+
<div role="alert" aria-live="polite" className="error-banner text-sm text-center py-[var(--spacing-inline)] rounded-lg px-[var(--spacing-3)]">
|
|
206
|
+
{error}
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
<button
|
|
211
|
+
type="submit"
|
|
212
|
+
disabled={isLoading}
|
|
213
|
+
className="w-full bg-teal-700 text-white py-2.5 rounded-lg font-medium hover:bg-teal-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
214
|
+
>
|
|
215
|
+
{isLoading ? "Verifying..." : isResetMode ? "Reset Password" : needsBootstrap ? "Create Owner Account" : "Login"}
|
|
216
|
+
</button>
|
|
217
|
+
</form>
|
|
218
|
+
|
|
219
|
+
<div className="mt-[var(--spacing-stack)] grid gap-[var(--spacing-inline)]">
|
|
220
|
+
{!needsBootstrap && !isResetMode && (
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
onClick={() => {
|
|
224
|
+
setNeedsBootstrap(true);
|
|
225
|
+
setError("");
|
|
226
|
+
}}
|
|
227
|
+
className="w-full text-xs text-slate-500 hover:text-slate-700 transition-colors"
|
|
228
|
+
>
|
|
229
|
+
First-time setup? Initialize admin owner account
|
|
230
|
+
</button>
|
|
231
|
+
)}
|
|
232
|
+
{!isResetMode && (
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={() => {
|
|
236
|
+
setIsResetMode(true);
|
|
237
|
+
setNeedsBootstrap(false);
|
|
238
|
+
setError("");
|
|
239
|
+
}}
|
|
240
|
+
className="w-full text-xs text-slate-500 hover:text-slate-700 transition-colors"
|
|
241
|
+
>
|
|
242
|
+
Have a reset token? Reset password
|
|
243
|
+
</button>
|
|
244
|
+
)}
|
|
245
|
+
{isResetMode && (
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
onClick={() => {
|
|
249
|
+
setIsResetMode(false);
|
|
250
|
+
setError("");
|
|
251
|
+
}}
|
|
252
|
+
className="w-full text-xs text-slate-500 hover:text-slate-700 transition-colors"
|
|
253
|
+
>
|
|
254
|
+
Back to login
|
|
255
|
+
</button>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|