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 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
+ }