authica 0.0.0-stage → 1.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sachin Mehta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM-------------------, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,131 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ <div align="center">
2
+
3
+ # ⚡ authica
4
+
5
+ **Zero-config CLI that inspects your design tokens and injects polished, animated authentication routes into React & Next.js apps in seconds.**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/authica.svg?style=for-the-badge&color=8B5CF6&labelColor=0F172A)](https://www.npmjs.com/package/authica)
8
+ [![npm downloads](https://img.shields.io/npm/dm/authica.svg?style=for-the-badge&color=3B82F6&labelColor=0F172A)](https://www.npmjs.com/package/authica)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg?style=for-the-badge&labelColor=0F172A)](https://opensource.org/licenses/MIT)
10
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-EC4899.svg?style=for-the-badge&labelColor=0F172A)](http://makeapullrequest.com)
11
+
12
+ <br />
13
+
14
+ ### npx authica@latest
15
+
16
+ [✨ Features](https://www.google.com/search?q=%23-features) • [🚀 Quickstart](https://www.google.com/search?q=%23-quickstart) • [🎨 Styles & Layouts](https://www.google.com/search?q=%23-styles--layouts) • [🔌 Auth Providers](https://www.google.com/search?q=%23-auth-providers) • [⚙️ Configuration](https://www.google.com/search?q=%23%25EF%25B8%258F-configuration)
17
+ </div>
18
+
19
+ ## 📸 Preview
20
+
21
+ ```
22
+ ┌─────────────────────────────────────────────────────────────────────────────┐
23
+ │ ⚡ authica v1.2.0 │
24
+ ├─────────────────────────────────────────────────────────────────────────────┤
25
+ │ │
26
+ │ ? Global CSS path? (src/index.css) │
27
+ │ ✔ Theme read from src/index.css (13 tokens) │
28
+ │ ? Layout: split, centered, canvas, modal, or glass? (split) │
29
+ │ ? Style preset: default, brutalist, or glassmorphism? (default) │
30
+ │ ? Auth provider: none, supabase, next-auth, or clerk? (supabase) │
31
+ │ ? Select OAuth providers: Google, GitHub, Apple │
32
+ │ │
33
+ │ ✔ Created src/routes/login.tsx │
34
+ │ ✔ Wired /login into src/main.tsx │
35
+ │ │
36
+ └─────────────────────────────────────────────────────────────────────────────┘
37
+ ```
38
+
39
+
40
+
41
+ ## ✨ Features
42
+
43
+ * **🎨 Auto Theme Scanning:** Automatically parses `:root` HSL, RGB, and OKLCH CSS variables from your `globals.css` or `index.css`.
44
+ * **🚀 Zero-Config Route Injection:** Safely creates routes and injects imports into Next.js App/Pages Router or Vite (`react-router-dom`) with automatic `.bak` safety backups.
45
+ * **🛡️ Pluggable Backend Handlers:** Generates pre-typed hooks for **Supabase**, **Clerk**, **Next-Auth**, or **Custom APIs**.
46
+ * **⚡ Zero Tailwind Lock-in:** Includes self-contained CSS fallbacks so animated cards look hyper-polished even without pre-configured Tailwind directives.
47
+ * **🎨 5 Layout Frameworks:** Choose between `split`, `centered`, `canvas`, `modal`, and `glass`.
48
+ * **✨ 3 Aesthetic Presets:** Toggle between `default` (Shadcn-esque), `brutalist` (hard shadows, 2px borders), and `glassmorphism` (frosted blur).
49
+ * **🔑 Pure Inline SVG OAuth:** Built-in crisp vector logos for **Google**, **GitHub**, **Apple**, **Discord**, and **Facebook**—no extra icon packages required.
50
+
51
+
52
+
53
+ ## 🚀 Quickstart
54
+
55
+ Run `authica` inside the root directory of any existing React or Next.js project:
56
+
57
+
58
+ >npx authica
59
+
60
+
61
+ ---
62
+ ### Non-Interactive One-Liner
63
+ ---
64
+ Skip prompts by passing flags directly:
65
+
66
+
67
+ >npx authica --layout=split --style=brutalist --auth=supabase --providers=google,github --yes
68
+
69
+ ---
70
+
71
+
72
+
73
+ ## 🎨 Styles & Layouts
74
+
75
+ | Layout | Description |
76
+ |---|---|
77
+ | `split` | Modern two-column hero layout with animated side-swapping cards on tab toggle. |
78
+ | `centered` | Clean, floating authentication card focused on high conversion and low cognitive load. |
79
+ | `canvas` | Full-screen interactive gradient canvas background with a centered glass form. |
80
+ | `modal` | Triggerable overlay dialog with `backdrop-filter` background blurring. |
81
+ | `glass` | Standalone translucent frosted card with glowing neon border accents. |
82
+
83
+ ---
84
+
85
+ | Preset | Visual Vibe |
86
+ |---|---|
87
+ | `default` | Neutral, high-contrast, subtle borders, and soft shadows (Shadcn aesthetic). |
88
+ | `brutalist` | Bold 2px dark borders, hard 4px offset drop-shadows, yellow active accents, and monospaced typography. |
89
+ | `glassmorphism` | Ultra-frosted blur backdrop (`backdrop-blur-xl`) over glowing radial background meshes. |
90
+
91
+ ---
92
+
93
+ ## 🔌 Auth Providers
94
+
95
+ | Backend | Flag | Features Handled |
96
+ |---|---|---|
97
+ | **None / Custom** | `--auth=none` | Pure unopinionated state & submit payload handlers. |
98
+ | **Supabase** | `--auth=supabase` | `@supabase/supabase-js` client initialization, `signInWithPassword`, `signUp`, and OAuth redirects. |
99
+ | **Clerk** | `--auth=clerk` | `@clerk/react` or `@clerk/nextjs` hooks, auto-created `/sso-callback` redirect route. |
100
+ | **Next-Auth** | `--auth=next-auth` | `signIn("credentials")` and provider mapping for Auth.js. |
101
+
102
+
103
+ ## ⚙️ Configuration (`.authicarc`)
104
+
105
+ Teams can enforce brand consistency across repositories by placing an `.authicarc` or `.authicarc.` file in the project root:
106
+
107
+ ```
108
+ {
109
+ "auth": "supabase",
110
+ "layout": "split",
111
+ "style": "default",
112
+ "providers": ["google", "github"],
113
+ "css": "src/index.css",
114
+ "name": true,
115
+ "confirm": true
116
+ }
117
+ ```
118
+
119
+
120
+ > **Precedence Order:** Command-line flags > `.authicarc` config file > Interactive CLI prompts.
121
+
122
+
123
+
124
+ ## 📄 License
125
+
126
+ Distributed under the MIT License. See [`LICENSE`](https://www.google.com/search?q=./LICENSE) for more information.
127
+
128
+ Made with ❤️ by [Sachin Mehta](https://github.com/AEGON247)
129
+
130
+ [★ Star on GitHub](https://www.google.com/search?q=https://github.com/AEGON247/authica)
131
+
package/bin/cli.js ADDED
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import readline from "node:readline/promises";
7
+
8
+ const args = process.argv.slice(2);
9
+ const created = [], modified = [], steps = [];
10
+ let themeNote = "", rcNote = "", extraRoutes = [];
11
+ const flag = (n) => args.includes(`--${n}`);
12
+ let rc = {};
13
+ const hasOpt = (n) => args.some((a) => a.startsWith(`--${n}=`)) || rc[n] !== undefined;
14
+ const opt = (n, d) => {
15
+ const a = args.find((x) => x.startsWith(`--${n}=`));
16
+ if (a) return a.slice(n.length + 3);
17
+ const v = rc[n];
18
+ return v === undefined ? d : Array.isArray(v) ? v.join(",") : String(v);
19
+ };
20
+ if (flag("help") || flag("h")) {
21
+ console.log("Usage: npx authica [--auth=none|supabase|next-auth|clerk] [--layout=split|centered|canvas|modal|glass] [--hero=<path|url>] [--providers=google,github,...] [--style=default|brutalist|glassmorphism] [--otp|--magic-link] [--css=<file>] [--entry=<file>] [--no-name] [--confirm] [--no-social] [--yes] [--force] [--no-install]");
22
+ process.exit(0);
23
+ }
24
+ const fail = (m) => { console.error(`✖ ${m}`); process.exit(1); };
25
+ const cwd = process.cwd();
26
+ const p = (...x) => path.join(cwd, ...x);
27
+ const ex = (f) => fs.existsSync(p(f));
28
+
29
+ // ---- team config (.authicarc / .authicarc.json); CLI flags win over it
30
+ const rcFile = [".authicarc", ".authicarc.json"].find(ex);
31
+ if (rcFile) {
32
+ try { rc = JSON.parse(fs.readFileSync(p(rcFile), "utf8")); }
33
+ catch (e) { fail(`${rcFile} is not valid JSON: ${e.message}`); }
34
+ if (!rc || typeof rc !== "object" || Array.isArray(rc)) fail(`${rcFile} must contain a JSON object.`);
35
+ const known = ["auth", "layout", "style", "method", "css", "hero", "entry", "providers", "name", "confirm", "social"];
36
+ const unknown = Object.keys(rc).filter((k) => !known.includes(k));
37
+ if (unknown.length) console.warn(`⚠ ${rcFile}: ignoring unknown keys: ${unknown.join(", ")}`);
38
+ rcNote = rcFile;
39
+ }
40
+ const auth = opt("auth", "none");
41
+ if (!["none", "supabase", "next-auth", "clerk"].includes(auth)) fail(`Unknown auth "${auth}"`);
42
+ if (!ex("package.json")) fail("No package.json found. Run this in your project root.");
43
+ const pkg = JSON.parse(fs.readFileSync(p("package.json"), "utf8"));
44
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
45
+
46
+ // ---- detect stack
47
+ const isNext = !!deps.next;
48
+ const isVite = !isNext && !!deps.vite && !!(deps["react-router-dom"] || deps["react-router"]);
49
+ if (!isNext && !isVite) fail("Unsupported project: need Next.js, or Vite + react-router(-dom).");
50
+ if (!deps.react) fail("React not found in package.json.");
51
+ if (!ex("tsconfig.json")) fail("TypeScript project required (tsconfig.json not found).");
52
+ if (auth === "next-auth" && !isNext) fail("--auth=next-auth requires Next.js.");
53
+
54
+ if (!deps.tailwindcss && !deps["@tailwindcss/vite"] && !deps["@tailwindcss/postcss"])
55
+ console.warn("⚠ Tailwind CSS not detected; using self-contained CSS fallbacks.");
56
+
57
+ // ---- interactive onboarding (falls back to defaults when non-interactive or --yes)
58
+ const interactive = process.stdin.isTTY && !flag("yes");
59
+ const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
60
+ const ask = async (q, def) => (rl ? (await rl.question(`${q} ${def !== "" ? `(${def}) ` : ""}`)).trim() || def : def);
61
+ const askBool = async (q, def) => {
62
+ const a = (await ask(`${q} [y/n]`, def ? "y" : "n")).toLowerCase();
63
+ return a.startsWith("y");
64
+ };
65
+
66
+ // ---- image path normalizer
67
+ function processHeroImage(inputPath) {
68
+ if (!inputPath || inputPath.trim() === "") return "";
69
+ const cleaned = inputPath.trim().replace(/^["']|["']$/g, ""); // Strip quotes
70
+
71
+ // 1. Return web URLs directly
72
+ if (/^https?:\/\//i.test(cleaned)) return cleaned;
73
+
74
+ // 2. Resolve absolute or relative path against project root
75
+ const absolutePath = path.isAbsolute(cleaned) ? cleaned : p(cleaned);
76
+ const publicDir = p("public");
77
+
78
+ if (!fs.existsSync(publicDir)) {
79
+ fs.mkdirSync(publicDir, { recursive: true });
80
+ }
81
+
82
+ // 3. If file is already inside public directory, return clean root-relative path (/filename)
83
+ const relativeToPublic = path.relative(publicDir, absolutePath);
84
+ if (!relativeToPublic.startsWith("..") && !path.isAbsolute(relativeToPublic)) {
85
+ return "/" + relativeToPublic.replace(/\\/g, "/");
86
+ }
87
+
88
+ // 4. If file exists outside public, copy it into public/ directory
89
+ if (fs.existsSync(absolutePath)) {
90
+ const ext = path.extname(absolutePath) || ".png";
91
+ const destName = `auth-hero${ext}`;
92
+ const destPath = path.join(publicDir, destName);
93
+ fs.copyFileSync(absolutePath, destPath);
94
+ created.push(`public/${destName}`);
95
+ return `/${destName}`;
96
+ }
97
+
98
+ // Fallback: assume clean root-relative path
99
+ const normalized = cleaned.replace(/\\/g, "/");
100
+ return normalized.startsWith("/") ? normalized : "/" + normalized;
101
+ }
102
+
103
+ // ---- theme scan
104
+ const cssCandidates = ["app/globals.css", "src/app/globals.css", "styles/globals.css", "src/styles/globals.css", "src/globals.css", "src/index.css", "app/global.css", "src/App.css"];
105
+ let cssFile = opt("css", "") || (await ask("Global CSS path?", cssCandidates.find(ex) || ""));
106
+ if (cssFile && !ex(cssFile)) { console.warn(`⚠ ${cssFile} not found; using fallback colors.`); cssFile = ""; }
107
+ const vars = {};
108
+ if (cssFile) {
109
+ const css = fs.readFileSync(p(cssFile), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
110
+ for (const m of css.matchAll(/--([\w-]+)\s*:\s*([^;}]+);/g)) if (!(m[1] in vars)) vars[m[1]] = m[2].trim();
111
+ themeNote = `${cssFile} (${Object.keys(vars).length} tokens)`;
112
+ } else console.warn("⚠ No global CSS found; using neutral fallback colors.");
113
+
114
+ const channels = /^-?[\d.]+(deg)?\s+[\d.]+%\s+[\d.]+%(\s*\/\s*[\d.%]+)?$/;
115
+ function tok(names, fb) {
116
+ for (const n of names) if (n in vars) return channels.test(vars[n]) ? `hsl(var(--${n}))` : `var(--${n},${fb})`;
117
+ for (const n of names) if (`color-${n}` in vars) return `var(--color-${n},${fb})`;
118
+ return fb;
119
+ }
120
+ const map = {
121
+ BG: tok(["background"], "#ffffff"),
122
+ FG: tok(["foreground"], "#0a0a0a"),
123
+ PRIMARY: tok(["primary"], "#4f5bff"),
124
+ PRIMARYFG: tok(["primary-foreground"], "#ffffff"),
125
+ MUTED: tok(["muted"], "#f4f4f5"),
126
+ BORDER: tok(["border"], "#e4e4e7"),
127
+ DESTRUCTIVE: tok(["destructive"], "#ef4444"),
128
+ };
129
+ map.CARD = tok(["card", "background"], "#ffffff");
130
+ map.CARDFG = tok(["card-foreground", "foreground"], "#0a0a0a");
131
+
132
+ // ---- layout / fields / hero choices
133
+ let layout = opt("layout", "") || (await ask("Layout: split, centered, canvas, modal or glass?", "split"));
134
+ if (!["split", "centered", "canvas", "modal", "glass"].includes(layout)) { console.warn(`⚠ Unknown layout "${layout}", using split.`); layout = "split"; }
135
+ let style = opt("style", "") || (await ask("Visual style: default, brutalist or glassmorphism?", "default"));
136
+ if (!["default", "brutalist", "glassmorphism"].includes(style)) { console.warn(`⚠ Unknown style "${style}", using default.`); style = "default"; }
137
+ let method = flag("otp") ? "otp" : flag("magic-link") ? "magic" : opt("method", "") || (await ask("Sign-in method: password, otp or magic-link?", "password"));
138
+ if (method === "magic-link") method = "magic";
139
+ if (!["password", "otp", "magic"].includes(method)) { console.warn(`⚠ Unknown method "${method}", using password.`); method = "password"; }
140
+ if (method === "magic" && auth === "clerk") fail("--magic-link is not supported with --auth=clerk (use --otp, or --auth=supabase/next-auth).");
141
+ const showName = flag("no-name") ? false : rc.name !== undefined ? !!rc.name : await askBool("Include full-name field?", true);
142
+ const confirmPw = method !== "password" ? false : flag("confirm") ? true : rc.confirm !== undefined ? !!rc.confirm : await askBool("Include confirm-password field?", false);
143
+ const ALL = ["google", "github", "apple", "discord", "facebook"];
144
+ let providers = [];
145
+ if (!flag("no-social") && rc.social !== false) {
146
+ const raw = hasOpt("providers") ? opt("providers", "") : (await ask(`Social providers, comma-separated (${ALL.join("/")}; blank = none)?`, "google,github"));
147
+ providers = raw.split(",").map((x) => x.trim().toLowerCase()).filter(Boolean);
148
+ const bad = providers.filter((x) => !ALL.includes(x));
149
+ if (bad.length) console.warn(`⚠ Ignoring unknown providers: ${bad.join(", ")}`);
150
+ providers = [...new Set(providers.filter((x) => ALL.includes(x)))];
151
+ }
152
+ const rawHero = !["split", "canvas"].includes(layout) ? "" : opt("hero", "") || (hasOpt("hero") ? "" : await ask("Hero image path or URL (blank = built-in artwork)?", ""));
153
+ const hero = processHeroImage(rawHero);
154
+
155
+ // ---- auth wiring
156
+ const env = (k) => (isNext ? `process.env.NEXT_PUBLIC_${k}` : `import.meta.env.VITE_${k}`);
157
+ const hooks = {
158
+ none: {
159
+ imports: "",
160
+ hook: `function useAuth() {
161
+ return {
162
+ submit: async (mode: Mode, d: Data) => { console.log(mode, d); /* TODO: connect your backend */ },
163
+ social: async (p: Social) => { console.log("social", p); /* TODO */ },
164
+ };
165
+ }`,
166
+ pkgs: [],
167
+ },
168
+ supabase: {
169
+ imports: `import { createClient } from "@supabase/supabase-js";\n`,
170
+ hook: `const supabase = createClient(${env("SUPABASE_URL")} as string, ${env("SUPABASE_ANON_KEY")} as string);
171
+
172
+ function useAuth() {
173
+ return {
174
+ submit: async (mode: Mode, d: Data) => {
175
+ const r =
176
+ mode === "login"
177
+ ? await supabase.auth.signInWithPassword({ email: d.email, password: d.password })
178
+ : await supabase.auth.signUp({ email: d.email, password: d.password, options: { data: { name: d.name } } });
179
+ if (r.error) throw r.error;
180
+ if (!r.data.session) throw new Error("Check your email to confirm your account.");
181
+ window.location.href = "/";
182
+ },
183
+ social: async (p: Social) => {
184
+ const { error } = await supabase.auth.signInWithOAuth({ provider: p });
185
+ if (error) throw error;
186
+ },
187
+ };
188
+ }`,
189
+ pkgs: ["@supabase/supabase-js"],
190
+ },
191
+ "next-auth": {
192
+ imports: `import { signIn } from "next-auth/react";\n`,
193
+ hook: `function useAuth() {
194
+ return {
195
+ submit: async (mode: Mode, d: Data) => {
196
+ if (mode === "signup") {
197
+ const r = await fetch("/api/register", {
198
+ method: "POST",
199
+ headers: { "Content-Type": "application/json" },
200
+ body: JSON.stringify(d),
201
+ });
202
+ if (!r.ok) throw new Error((await r.text()) || "Sign up failed");
203
+ }
204
+ const res = await signIn("credentials", { email: d.email, password: d.password, redirect: false });
205
+ if (res?.error) throw new Error("Invalid credentials");
206
+ window.location.href = "/";
207
+ },
208
+ social: async (p: Social) => { await signIn(p, { callbackUrl: "/" }); },
209
+ };
210
+ }`,
211
+ pkgs: ["next-auth"],
212
+ },
213
+ clerk: {
214
+ imports: `import { useSignIn, useSignUp } from "${isNext ? "@clerk/nextjs" : "@clerk/react"}";\n`,
215
+ hook: `function useAuth() {
216
+ const { signIn, setActive: setSignInActive } = useSignIn();
217
+ const { signUp, setActive: setSignUpActive } = useSignUp();
218
+ return {
219
+ submit: async (mode: Mode, d: Data) => {
220
+ if (mode === "login") {
221
+ const r = await signIn!.create({ identifier: d.email, password: d.password });
222
+ if (r.status !== "complete") throw new Error("Additional verification required.");
223
+ await setSignInActive!({ session: r.createdSessionId });
224
+ } else {
225
+ const r = await signUp!.create({ emailAddress: d.email, password: d.password, firstName: d.name });
226
+ if (r.status !== "complete") throw new Error("Check your email to verify your account.");
227
+ await setSignUpActive!({ session: r.createdSessionId });
228
+ }
229
+ window.location.href = "/";
230
+ },
231
+ social: async (p: Social) => {
232
+ // Requires a /sso-callback route rendering Clerk's AuthenticateWithRedirectCallback.
233
+ await signIn!.authenticateWithRedirect({
234
+ strategy: ("oauth_" + p) as "oauth_google",
235
+ redirectUrl: "/sso-callback",
236
+ redirectUrlComplete: "/",
237
+ });
238
+ },
239
+ };
240
+ }`,
241
+ pkgs: [isNext ? "@clerk/nextjs" : "@clerk/react"],
242
+ },
243
+ }[auth];
244
+
245
+ const pw = method === "password";
246
+ const stub = ` send: async (_m: Mode, _d: Data) => {},
247
+ verify: async (_m: Mode, _e: string, _c: string) => {},`;
248
+ const extras = {
249
+ none: ` send: async (mode: Mode, d: Data) => { console.log("send", mode, d); /* TODO */ },
250
+ verify: async (mode: Mode, email: string, code: string) => { console.log("verify", mode, email, code); /* TODO */ },`,
251
+ supabase: ` send: async (mode: Mode, d: Data) => {
252
+ const { error } = await supabase.auth.signInWithOtp({
253
+ email: d.email,
254
+ options: {
255
+ shouldCreateUser: mode === "signup",
256
+ data: mode === "signup" ? { name: d.name } : undefined,
257
+ emailRedirectTo: window.location.origin,
258
+ },
259
+ });
260
+ if (error) throw error;
261
+ },
262
+ verify: async (_m: Mode, email: string, code: string) => {
263
+ const { error } = await supabase.auth.verifyOtp({ email, token: code, type: "email" });
264
+ if (error) throw error;
265
+ window.location.href = "/";
266
+ },`,
267
+ "next-auth": ` send: async (_m: Mode, d: Data) => {
268
+ // Needs an Email provider (id "email" in next-auth v4) configured on the server.
269
+ const res = await signIn("email", { email: d.email, redirect: false, callbackUrl: "/" });
270
+ if (res?.error) throw new Error("Could not send the email.");
271
+ },
272
+ verify: async (_m: Mode, email: string, code: string) => {
273
+ // Needs a custom generateVerificationToken on the Email provider that returns a 6-digit code.
274
+ window.location.href = "/api/auth/callback/email?email=" + encodeURIComponent(email) + "&token=" + encodeURIComponent(code) + "&callbackUrl=%2F";
275
+ },`,
276
+ clerk: ` send: async (mode: Mode, d: Data) => {
277
+ if (mode === "login") {
278
+ const r = await signIn!.create({ identifier: d.email });
279
+ const f = r.supportedFirstFactors?.find((x) => x.strategy === "email_code") as unknown as { emailAddressId: string } | undefined;
280
+ if (!f) throw new Error("Email code sign-in is not enabled for this account.");
281
+ await signIn!.prepareFirstFactor({ strategy: "email_code", emailAddressId: f.emailAddressId });
282
+ } else {
283
+ await signUp!.create({ emailAddress: d.email, firstName: d.name });
284
+ await signUp!.prepareEmailAddressVerification({ strategy: "email_code" });
285
+ }
286
+ },
287
+ verify: async (mode: Mode, _e: string, code: string) => {
288
+ if (mode === "login") {
289
+ const r = await signIn!.attemptFirstFactor({ strategy: "email_code", code });
290
+ if (r.status !== "complete") throw new Error("Additional verification required.");
291
+ await setSignInActive!({ session: r.createdSessionId });
292
+ } else {
293
+ const r = await signUp!.attemptEmailAddressVerification({ code });
294
+ if (r.status !== "complete") throw new Error("Additional sign-up steps required.");
295
+ await setSignUpActive!({ session: r.createdSessionId });
296
+ }
297
+ window.location.href = "/";
298
+ },`,
299
+ };
300
+ hooks.hook = hooks.hook.replace(/\n \};\n\}$/, () => "\n" + (pw ? stub : extras[auth]) + "\n };\n}");
301
+ if (!hooks.hook.includes("send:")) fail("Internal error: could not attach auth handlers.");
302
+
303
+ // ---- render template
304
+ const here = path.dirname(fileURLToPath(import.meta.url));
305
+ const rep = (t, k, v) => t.split(`__${k}__`).join(v);
306
+ let src = fs.readFileSync(path.join(here, "..", "templates", "authica.tsx.tpl"), "utf8");
307
+ src = src.replace("__DIRECTIVE__", () => (isNext ? '"use client";\n\n' : ""))
308
+ .replace("__AUTH_IMPORTS__", () => hooks.imports)
309
+ .replace("__AUTH_HOOK__", () => hooks.hook);
310
+ for (const [k, v] of Object.entries({ ...map, LAYOUT: layout, STYLE: style, METHOD: method, SHOW_NAME: String(showName), CONFIRM: String(confirmPw), PROVIDERS: JSON.stringify(providers), HERO: JSON.stringify(hero) })) src = rep(src, k, v);
311
+ if (/__[A-Z]+__/.test(src)) fail("Unresolved template placeholder (internal error).");
312
+
313
+ // ---- target path
314
+ let target;
315
+ if (isNext) {
316
+ const base = ["app", "src/app"].find(ex);
317
+ target = base ? `${base}/(auth)/login/page.tsx` : `${["pages", "src/pages"].find(ex) || fail("No app/ or pages/ directory found.")}/login.tsx`;
318
+ } else target = "src/routes/login.tsx";
319
+ if (ex(target) && !flag("force")) fail(`${target} already exists. Use --force to overwrite.`);
320
+ fs.mkdirSync(path.dirname(p(target)), { recursive: true });
321
+ fs.writeFileSync(p(target), src);
322
+ created.push(target);
323
+
324
+ // ---- vite route injection
325
+ if (isVite) {
326
+ const detected = ["src/main.tsx", "src/App.tsx"].find((f) => ex(f) && /<Routes[\s>]|create\w*Router\(\s*\[/.test(fs.readFileSync(p(f), "utf8"))) || "src/App.tsx";
327
+ const entry = opt("entry", "") || (await ask("File that defines your routes?", detected));
328
+ const file = ex(entry) && /<Routes[\s>]|create\w*Router\(\s*\[/.test(fs.readFileSync(p(entry), "utf8")) ? entry : null;
329
+ const manual = () => steps.push(`Add a /login route in your router: import authica from "./routes/login"; { path: "/login", element: <authica /> }`);
330
+ if (!file) manual();
331
+ else {
332
+ let c = fs.readFileSync(p(file), "utf8");
333
+ if (c.includes("authica")) modified.push(`${file} (already wired)`);
334
+ else {
335
+ let rel = path.posix.relative(path.posix.dirname(file), "src/routes/login");
336
+ if (!rel.startsWith(".")) rel = "./" + rel;
337
+ if (/<Routes[\s>]/.test(c)) c = c.replace(/<Routes(\s[^>]*)?>/, (m) => `${m}\n <Route path="/login" element={<authica />} />`);
338
+ else c = c.replace(/(create\w*Router\(\s*\[)/, `$1\n { path: "/login", element: <authica /> },`);
339
+ let end = 0;
340
+ for (const m of c.matchAll(/^import\s[^;]*?['"][^'"]+['"];?[ \t]*$/gm)) end = Math.max(end, m.index + m[0].length);
341
+ const imp = `import authica from "${rel}";`;
342
+ c = end ? c.slice(0, end) + "\n" + imp + c.slice(end) : imp + "\n" + c;
343
+ const dirty = spawnSync("git", ["status", "--porcelain", "--", file], { cwd, encoding: "utf8" });
344
+ if (dirty.status === 0 && dirty.stdout.trim()) console.warn(`⚠ ${file} has uncommitted changes.`);
345
+ if (!ex(file + ".bak")) fs.copyFileSync(p(file), p(file + ".bak"));
346
+ fs.writeFileSync(p(file), c);
347
+ modified.push(`${file} (+/login route, backup ${file}.bak)`);
348
+ }
349
+ }
350
+ }
351
+
352
+ // ---- provider notices / Clerk SSO callback route
353
+ if (providers.length) {
354
+ if (auth === "clerk") {
355
+ const pkgName = isNext ? "@clerk/nextjs" : "@clerk/react";
356
+ const cb = `${isNext ? '"use client";\n\n' : ""}import { AuthenticateWithRedirectCallback } from "${pkgName}";\n\nexport default function SSOCallback() {\n return <AuthenticateWithRedirectCallback />;\n}\n`;
357
+ let cbTarget;
358
+ if (isNext) {
359
+ const appBase = ["app", "src/app"].find(ex);
360
+ cbTarget = appBase ? `${appBase}/sso-callback/page.tsx` : `${["pages", "src/pages"].find(ex)}/sso-callback.tsx`;
361
+ } else cbTarget = "src/routes/sso-callback.tsx";
362
+ if (!ex(cbTarget) || flag("force")) {
363
+ fs.mkdirSync(path.dirname(p(cbTarget)), { recursive: true });
364
+ fs.writeFileSync(p(cbTarget), cb);
365
+ created.push(cbTarget);
366
+ extraRoutes.push(isVite ? "/sso-callback (route to add)" : "/sso-callback");
367
+ }
368
+ steps.push(`Clerk dashboard: enable ${providers.join(", ")} under Social connections.`);
369
+ if (isVite) steps.push(`Add a /sso-callback route: import SSOCallback from "./routes/sso-callback"; { path: "/sso-callback", element: <SSOCallback /> }`);
370
+ } else if (auth === "next-auth") {
371
+ steps.push(`next-auth config: add providers ${providers.join(", ")} (ids must match).`);
372
+ } else if (auth === "supabase") {
373
+ steps.push(`Supabase dashboard: enable ${providers.join(", ")} under Authentication → Providers.`);
374
+ }
375
+ }
376
+ if (method === "otp" && auth === "supabase") steps.push("Supabase email templates: include {{ .Token }} so users receive the 6-digit code.");
377
+ if (method !== "password" && auth === "next-auth") steps.push('next-auth: add an Email provider (id "email")' + (method === "otp" ? " with generateVerificationToken returning 6 digits." : "."));
378
+
379
+ // ---- dependencies
380
+ const need = ["framer-motion", ...hooks.pkgs].filter((d) => !deps[d]);
381
+ if (need.length && !flag("no-install")) {
382
+ const pm = ex("pnpm-lock.yaml") ? "pnpm" : ex("yarn.lock") ? "yarn" : ex("bun.lockb") || ex("bun.lock") ? "bun" : "npm";
383
+ console.log(`Installing ${need.join(", ")} with ${pm}…`);
384
+ const r = spawnSync(pm, [pm === "npm" ? "install" : "add", ...need], { cwd, stdio: "inherit", shell: process.platform === "win32" });
385
+ if (r.status !== 0) steps.unshift(`Install failed — run: ${pm} ${pm === "npm" ? "install" : "add"} ${need.join(" ")}`);
386
+ } else if (need.length) steps.unshift(`Install: npm install ${need.join(" ")}`);
387
+ rl?.close();
388
+
389
+ // ---- summary box
390
+ const pre = isNext ? "NEXT_PUBLIC_" : "VITE_";
391
+ const envVars = {
392
+ none: [],
393
+ supabase: [pre + "SUPABASE_URL", pre + "SUPABASE_ANON_KEY"],
394
+ clerk: isNext ? ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"] : ["VITE_CLERK_PUBLISHABLE_KEY"],
395
+ "next-auth": ["NEXTAUTH_URL", "NEXTAUTH_SECRET"],
396
+ }[auth];
397
+ const next = [...steps];
398
+ if (envVars.length) next.splice(next.findIndex((x) => !x.startsWith("Install")) < 0 ? next.length : next.findIndex((x) => !x.startsWith("Install")), 0, `Add to ${isNext ? ".env.local" : ".env"}: ${envVars.join(", ")}`);
399
+ if (auth === "clerk") next.push(`Wrap your app in <ClerkProvider publishableKey={...}>.`);
400
+ if (auth === "next-auth") next.push("OAuth client ID/secret for each provider go in your NextAuth config.");
401
+ if (layout === "modal") next.push('Modal: use as the /login route, or render <authica trigger="Sign in" /> (props: open, onClose, trigger).');
402
+ next.push("Start your dev server and open /login.");
403
+ const routes = ["/login", ...extraRoutes];
404
+ const rows = [
405
+ ["Routes", routes.join(", ")],
406
+ ["Auth", auth === "none" ? "none (UI only; wire your backend)" : auth],
407
+ ["Providers", providers.length ? providers.join(", ") : "none"],
408
+ ["Layout", `${layout} · ${style}`],
409
+ ["Sign-in", { password: "password", otp: "6-digit code (OTP)", magic: "magic link" }[method]],
410
+ ...(themeNote ? [["Theme", themeNote]] : []),
411
+ ...(rcNote ? [["Config", rcNote]] : []),
412
+ ...created.map((x, i) => [i ? "" : "Created", x]),
413
+ ...modified.map((x, i) => [i ? "" : "Modified", x]),
414
+ ];
415
+ const W = Math.max(40, Math.min((process.stdout.columns || 80) - 2, 78)), inner = W - 4;
416
+ const wrap = (prefix, text, ind) => {
417
+ const out = []; let cur = prefix;
418
+ for (const w of text.split(" ")) {
419
+ const sep = cur.trim() === "" || cur === prefix ? "" : " ";
420
+ if (cur.trim() !== "" && cur.length > ind && (cur + sep + w).length > inner) { out.push(cur); cur = " ".repeat(ind) + w; }
421
+ else cur += sep + w;
422
+ }
423
+ out.push(cur);
424
+ return out;
425
+ };
426
+ const body = [];
427
+ for (const [k, v] of rows) body.push(...wrap(k.padEnd(11), v, 11));
428
+ body.push("");
429
+ body.push("Next steps");
430
+ next.forEach((x, i) => body.push(...wrap(`${i + 1}. `, x, 3)));
431
+ const title = " authica ✔ ";
432
+ console.log("\n╭" + title + "─".repeat(W - 2 - title.length) + "╮");
433
+ for (const l of body) console.log("│ " + l.padEnd(inner) + " │");
434
+ console.log("╰" + "─".repeat(W - 2) + "╯");
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "authica",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Adds a theme-matched, animated sliding sign-in/sign-up page (split, centered or canvas) to Next.js or Vite + React Router projects.",
5
+ "type": "module",
6
+ "bin": {
7
+ "authica": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "templates"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "license": "MIT"
17
+ }
@@ -0,0 +1,343 @@
1
+ __DIRECTIVE__import { useEffect, useState, type FormEvent, type InputHTMLAttributes, type ReactNode } from "react";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ __AUTH_IMPORTS__
4
+ type Mode = "login" | "signup";
5
+ type Social = "google" | "github" | "apple" | "discord" | "facebook";
6
+ type Method = "password" | "otp" | "magic";
7
+ type Data = { name: string; email: string; password: string };
8
+
9
+ // ---- Generated options (edit freely) ----
10
+ const LAYOUT = "__LAYOUT__" as "split" | "centered" | "canvas" | "modal" | "glass";
11
+ const STYLE = "__STYLE__" as "default" | "brutalist" | "glassmorphism";
12
+ const METHOD = "__METHOD__" as Method;
13
+ const PASSWORDLESS = METHOD !== "password";
14
+ const RESEND_SECONDS = 30;
15
+ const SHOW_NAME = __SHOW_NAME__ as boolean;
16
+ const CONFIRM_PASSWORD = __CONFIRM__ as boolean;
17
+ const PROVIDERS = __PROVIDERS__ as Social[];
18
+ const HERO_IMAGE = __HERO__ as string;
19
+
20
+ __AUTH_HOOK__
21
+
22
+ // Self-contained styles: no Tailwind needed. Colors come from your theme variables, with fallbacks.
23
+ const CSS = `
24
+ .as-root,.as-vars{--as-bg:__BG__;--as-fg:__FG__;--as-card:__CARD__;--as-cardfg:__CARDFG__;--as-primary:__PRIMARY__;--as-primaryfg:__PRIMARYFG__;--as-muted:__MUTED__;--as-border:__BORDER__;--as-danger:__DESTRUCTIVE__;--as-ok:#16a34a}
25
+ .as-root{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;background:var(--as-bg);color:var(--as-fg);box-sizing:border-box}
26
+ .as-root *,.as-vars *{box-sizing:border-box}
27
+ .as-card{position:relative;display:flex;width:100%;max-width:440px;background:var(--as-card);color:var(--as-cardfg);border:1px solid var(--as-border);border-radius:var(--radius,1rem);overflow:hidden;box-shadow:0 20px 50px rgba(0,0,0,.15)}
28
+ .as-split{max-width:980px;min-height:620px}
29
+ .as-canvas,.as-s-glassmorphism,.as-glass{background:radial-gradient(60% 60% at 20% 10%,color-mix(in srgb,var(--as-primary) 55%,transparent),transparent),radial-gradient(50% 50% at 85% 90%,color-mix(in srgb,var(--as-primary) 35%,transparent),transparent),#0b0b12;background-size:cover;background-position:center;color:#fff}
30
+ .as-canvas .as-card,.as-s-glassmorphism .as-card,.as-glass .as-card{background:rgba(255,255,255,.08);color:#fff;border-color:rgba(255,255,255,.18);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px)}
31
+ .as-canvas .as-tabs,.as-s-glassmorphism .as-tabs,.as-glass .as-tabs{background:rgba(255,255,255,.1)}.as-canvas .as-pill,.as-s-glassmorphism .as-pill,.as-glass .as-pill{background:rgba(255,255,255,.22)}
32
+ .as-canvas .as-field,.as-canvas .as-sbtn,.as-s-glassmorphism .as-field,.as-glass .as-field,.as-s-glassmorphism .as-sbtn,.as-glass .as-sbtn{border-color:rgba(255,255,255,.25)}
33
+ .as-canvas .as-field input::placeholder,.as-s-glassmorphism .as-field input::placeholder,.as-glass .as-field input::placeholder{color:rgba(255,255,255,.55)}
34
+ .as-s-glassmorphism .as-card{box-shadow:0 0 0 1px rgba(255,255,255,.12),0 0 90px -10px color-mix(in srgb,var(--as-primary) 65%,transparent)}
35
+ .as-s-glassmorphism .as-btn{box-shadow:0 0 24px color-mix(in srgb,var(--as-primary) 70%,transparent)}
36
+ .as-glass{background:radial-gradient(40% 40% at 15% 20%,color-mix(in srgb,var(--as-primary) 60%,transparent),transparent),radial-gradient(35% 35% at 85% 15%,rgba(168,85,247,.45),transparent),radial-gradient(40% 40% at 80% 85%,rgba(6,182,212,.4),transparent),radial-gradient(35% 35% at 10% 90%,rgba(236,72,153,.3),transparent),#07070d}
37
+ .as-glass .as-card{background:rgba(255,255,255,.05);backdrop-filter:blur(32px) saturate(150%);-webkit-backdrop-filter:blur(32px) saturate(150%);border:1px solid color-mix(in srgb,var(--as-primary) 55%,rgba(255,255,255,.2));box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 0 32px color-mix(in srgb,var(--as-primary) 45%,transparent),0 0 90px -20px color-mix(in srgb,var(--as-primary) 60%,transparent)}
38
+ .as-glass .as-btn{box-shadow:0 0 24px color-mix(in srgb,var(--as-primary) 70%,transparent)}
39
+ .as-overlay{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:16px;overflow:auto;background:rgba(0,0,0,.45);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
40
+ .as-close{position:absolute;top:10px;right:12px;z-index:2;width:32px;height:32px;border:0;border-radius:999px;background:transparent;color:inherit;font-size:22px;line-height:1;cursor:pointer;opacity:.7}.as-close:hover{opacity:1}
41
+ .as-trigger{width:auto;padding:10px 20px}
42
+ .as-form{flex:1 1 0;min-width:0;padding:40px;display:flex;flex-direction:column;justify-content:center}
43
+ .as-hero{flex:1 1 0;display:none;position:relative;overflow:hidden;background:var(--as-primary);background-size:cover;background-position:center;color:var(--as-primaryfg)}
44
+ .as-hero.as-img::after{content:"";position:absolute;inset:0;background:linear-gradient(transparent 35%,rgba(0,0,0,.6))}
45
+ @media(min-width:860px){.as-split .as-hero{display:block}}
46
+ .as-shape{position:absolute;border-radius:32px;background:rgba(255,255,255,.14)}
47
+ .as-hcard{position:absolute;z-index:1;background:var(--as-card);color:var(--as-cardfg);border-radius:14px;padding:16px;box-shadow:0 10px 30px rgba(0,0,0,.25);font-size:13px}
48
+ .as-htext{position:absolute;z-index:1;left:32px;right:32px;bottom:32px}
49
+ .as-htext h2{margin:0 0 6px;font-size:26px;font-weight:700}.as-htext p{margin:0;opacity:.85;font-size:14px}
50
+ .as-tabs{position:relative;display:flex;background:var(--as-muted);padding:4px;border-radius:10px}
51
+ .as-tab{position:relative;z-index:1;flex:1;padding:8px;font:inherit;font-size:14px;font-weight:500;border:0;background:none;color:inherit;cursor:pointer}
52
+ .as-pill{position:absolute;inset:0;z-index:-1;border-radius:7px;background:var(--as-bg);box-shadow:0 1px 3px rgba(0,0,0,.2)}
53
+ .as-title{font-size:30px;font-weight:700;margin:28px 0 4px}.as-sub{font-size:13px;opacity:.6;margin:0 0 20px}
54
+ .as-field{display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--as-border);padding:10px 2px;margin-bottom:14px}
55
+ .as-field:focus-within{border-color:var(--as-primary)}
56
+ .as-field svg{width:18px;height:18px;flex:none;opacity:.5}.as-field svg.as-ok{color:var(--as-ok);opacity:1}
57
+ .as-field input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;font-size:14px}
58
+ .as-rules{list-style:none;margin:-6px 0 14px;padding:0;font-size:12px}
59
+ .as-rules li{opacity:.55}.as-rules li.on{color:var(--as-ok);opacity:1}
60
+ .as-err{color:var(--as-danger);font-size:13px;margin:0 0 10px}
61
+ .as-btn{width:100%;border:0;border-radius:999px;padding:12px;font:inherit;font-size:14px;font-weight:600;background:var(--as-primary);color:var(--as-primaryfg);cursor:pointer}
62
+ .as-btn:disabled{opacity:.6;cursor:default}
63
+ .as-social{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.as-sbtn svg{width:18px;height:18px;flex:none}
64
+ .as-link{display:block;width:100%;margin-top:10px;padding:6px;border:0;background:none;color:inherit;font:inherit;font-size:13px;opacity:.75;cursor:pointer;text-decoration:underline}.as-link:disabled{opacity:.45;cursor:default;text-decoration:none}
65
+ .as-s-brutalist{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
66
+ .as-s-brutalist .as-card{background:#fff;color:#000;border:2px solid #000;border-radius:0;box-shadow:4px 4px 0 0 #000;backdrop-filter:none;-webkit-backdrop-filter:none}
67
+ .as-s-brutalist .as-tabs{background:#fff;border:2px solid #000;border-radius:0;padding:0}
68
+ .as-s-brutalist .as-tab{font-weight:700;text-transform:uppercase}
69
+ .as-s-brutalist .as-pill{background:#ffe14d;border-radius:0;box-shadow:none}
70
+ .as-s-brutalist .as-field{background:#fff;border:2px solid #000;border-radius:0;padding:10px}
71
+ .as-s-brutalist .as-field:focus-within{border-color:#000;box-shadow:4px 4px 0 0 #000}
72
+ .as-s-brutalist .as-field input::placeholder{color:rgba(0,0,0,.5)}
73
+ .as-s-brutalist .as-btn,.as-s-brutalist .as-sbtn{border:2px solid #000;border-radius:0;box-shadow:4px 4px 0 0 #000;font-weight:700;text-transform:uppercase}
74
+ .as-s-brutalist .as-sbtn{background:#fff;color:#000}
75
+ .as-s-brutalist .as-btn:active,.as-s-brutalist .as-sbtn:active{transform:translate(4px,4px);box-shadow:none}
76
+ .as-s-brutalist .as-hero{border-left:2px solid #000}
77
+ .as-s-brutalist .as-hcard{border:2px solid #000;border-radius:0;box-shadow:4px 4px 0 0 #000}
78
+ .as-s-brutalist .as-tab,.as-s-brutalist .as-link{color:#000}
79
+ .as-sbtn{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;padding:10px;border:1px solid var(--as-border);border-radius:999px;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer}
80
+ `;
81
+
82
+ const ICONS = {
83
+ user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
84
+ mail: "M3 5h18v14H3zM3 7l9 6 9-6",
85
+ lock: "M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4",
86
+ check: "M5 13l4 4L19 7",
87
+ };
88
+ const BRANDS: Record<Social, { label: string; svg: ReactNode }> = {
89
+ google: { label: "Google", svg: (<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#4285F4" d="M23.5 12.3c0-.8-.1-1.6-.2-2.3H12v4.5h6.5a5.6 5.6 0 0 1-2.4 3.6v3h3.9c2.3-2.1 3.5-5.2 3.5-8.8z"/><path fill="#34A853" d="M12 24c3.2 0 6-1.1 7.9-2.9l-3.9-3c-1.1.7-2.5 1.2-4 1.2-3.1 0-5.7-2.1-6.6-4.9H1.4v3.1A12 12 0 0 0 12 24z"/><path fill="#FBBC05" d="M5.4 14.3a7.2 7.2 0 0 1 0-4.6V6.6H1.4a12 12 0 0 0 0 10.8z"/><path fill="#EA4335" d="M12 4.8c1.8 0 3.3.6 4.6 1.8l3.4-3.4A12 12 0 0 0 1.4 6.6l4 3.1C6.3 6.9 8.9 4.8 12 4.8z"/></svg>) },
90
+ github: { label: "GitHub", svg: (<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.7.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5z"/></svg>) },
91
+ apple: { label: "Apple", svg: (<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.4 2-3.5 2.1-3.6-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9s-2-.9-3.2-.8A4.8 4.8 0 0 0 3.8 9.6c-1.8 3.1-.5 7.7 1.3 10.2.9 1.2 1.9 2.6 3.2 2.5 1.3-.1 1.8-.8 3.3-.8s2 .8 3.3.8c1.4 0 2.3-1.2 3.100-2.5a11 11 0 0 0 1.400-2.900c-.1 0-2.600-1-2.600-4.300zM14 5.200a4.500 4.500 0 0 0 1-3.200 4.600 4.600 0 0 0-3 1.500 4.300 4.300 0 0 0-1.100 3.100A3.800 3.800 0 0 0 14 5.200z"/></svg>) },
92
+ discord: { label: "Discord", svg: (<svg viewBox="0 0 24 24" fill="#5865F2" aria-hidden="true"><path d="M20.3 4.4A19.800 19.800 0 0 0 15.400 3l-.2.500a18.400 18.400 0 0 0-6.400 0L8.600 3a19.700 19.700 0 0 0-4.900 1.400C.600 9 -.200 13.500.200 18a19.900 19.900 0 0 0 6 3l1.300-2.100c-.7-.3-1.400-.600-2-1l.5-.4a14.200 14.200 0 0 0 12 0l.5.400c-.6.400-1.300.700-2 1l1.300 2.100a19.900 19.900 0 0 0 6-3c.5-5.200-.8-9.700-3.500-13.600zM8.300 15.300c-1.200 0-2.100-1.100-2.100-2.400s.9-2.400 2.100-2.400 2.100 1.100 2.100 2.400-.9 2.400-2.100 2.400zm7.400 0c-1.200 0-2.100-1.100-2.100-2.400s.9-2.400 2.100-2.400 2.100 1.100 2.100 2.400-.9 2.400-2.100 2.400z"/></svg>) },
93
+ facebook: { label: "Facebook", svg: (<svg viewBox="0 0 24 24" fill="#1877F2" aria-hidden="true"><path d="M24 12a12 12 0 1 0-13.900 11.900v-8.400h-3V12h3V9.400c0-3 1.800-4.700 4.500-4.700 1.300 0 2.700.2 2.700.2v3h-1.500c-1.500 0-2 .9-2 1.900V12h3.400l-.5 3.500h-2.800v8.400A12 12 0 0 0 24 12z"/></svg>) },
94
+ };
95
+ function Icon({ d, ok }: { d: string; ok?: boolean }) {
96
+ return (
97
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={ok ? "as-ok" : undefined} aria-hidden="true">
98
+ <path d={d} />
99
+ </svg>
100
+ );
101
+ }
102
+ function Field({ icon, valid, ...p }: { icon: string; valid: boolean } & InputHTMLAttributes<HTMLInputElement>) {
103
+ return (
104
+ <label className="as-field">
105
+ <Icon d={icon} />
106
+ <input {...p} />
107
+ {valid && <Icon d={ICONS.check} ok />}
108
+ </label>
109
+ );
110
+ }
111
+
112
+ type ModalProps = { open?: boolean; onClose?: () => void; trigger?: ReactNode };
113
+
114
+ export default function authica({ open, onClose, trigger }: ModalProps = {}) {
115
+ const routeMode = open === undefined && trigger === undefined;
116
+ const [inner, setInner] = useState(routeMode);
117
+ const isOpen = open ?? inner;
118
+ const close = () => {
119
+ setInner(false);
120
+ if (onClose) onClose();
121
+ else if (routeMode) { if (window.history.length > 1) window.history.back(); else window.location.href = "/"; }
122
+ };
123
+ useEffect(() => {
124
+ if (LAYOUT !== "modal" || !isOpen) return;
125
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
126
+ const prev = document.body.style.overflow;
127
+ document.body.style.overflow = "hidden";
128
+ window.addEventListener("keydown", onKey);
129
+ return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
130
+ // eslint-disable-next-line react-hooks/exhaustive-deps
131
+ }, [isOpen]);
132
+ const [mode, setMode] = useState<Mode>("login");
133
+ const [v, setV] = useState({ name: "", email: "", password: "", confirm: "" });
134
+ const [error, setError] = useState("");
135
+ const [loading, setLoading] = useState(false);
136
+ const [step, setStep] = useState<"form" | "code">("form");
137
+ const [code, setCode] = useState("");
138
+ const [left, setLeft] = useState(0);
139
+ const { submit, social, send, verify } = useAuth();
140
+ const isLogin = mode === "login";
141
+ const split = LAYOUT === "split";
142
+ const set = (k: keyof typeof v) => (e: { target: { value: string } }) => setV({ ...v, [k]: e.target.value });
143
+
144
+ const rules = [
145
+ ["At least 8 characters", v.password.length >= 8],
146
+ ["A number or a symbol", /[\d\W_]/.test(v.password)],
147
+ ["Lowercase and uppercase letters", /[a-z]/.test(v.password) && /[A-Z]/.test(v.password)],
148
+ ] as const;
149
+ useEffect(() => {
150
+ if (left <= 0) return;
151
+ const t = setTimeout(() => setLeft((n) => n - 1), 1000);
152
+ return () => clearTimeout(t);
153
+ }, [left]);
154
+
155
+ const emailOk = /^\S+@\S+\.\S+$/.test(v.email);
156
+
157
+ async function run(fn: () => Promise<void>) {
158
+ setError("");
159
+ setLoading(true);
160
+ try {
161
+ await fn();
162
+ } catch (err) {
163
+ setError(err instanceof Error ? err.message : "Something went wrong");
164
+ } finally {
165
+ setLoading(false);
166
+ }
167
+ }
168
+
169
+ function resend() {
170
+ run(async () => {
171
+ await send(mode, { name: v.name, email: v.email, password: "" });
172
+ setLeft(RESEND_SECONDS);
173
+ });
174
+ }
175
+
176
+ function onSubmit(e: FormEvent<HTMLFormElement>) {
177
+ e.preventDefault();
178
+ if (step === "code") return run(() => verify(mode, v.email, code));
179
+ if (PASSWORDLESS) {
180
+ return run(async () => {
181
+ await send(mode, { name: v.name, email: v.email, password: "" });
182
+ setStep("code");
183
+ setLeft(RESEND_SECONDS);
184
+ });
185
+ }
186
+ if (!isLogin) {
187
+ if (!rules.every((r) => r[1])) return setError("Password does not meet the requirements.");
188
+ if (CONFIRM_PASSWORD && v.password !== v.confirm) return setError("Passwords do not match.");
189
+ }
190
+ run(() => submit(mode, { name: v.name, email: v.email, password: v.password }));
191
+ }
192
+
193
+ const spring = { type: "spring", stiffness: 300, damping: 30 } as const;
194
+ const formPanel = (
195
+ <motion.div layout="position" transition={spring} className="as-form" style={{ order: isLogin ? 1 : 2 }}>
196
+ <div className="as-tabs">
197
+ {(["login", "signup"] as const).map((m) => (
198
+ <button key={m} type="button" className="as-tab" onClick={() => { setMode(m); setError(""); setStep("form"); setCode(""); setLeft(0); }}>
199
+ {mode === m && <motion.span layoutId="as-pill" className="as-pill" transition={spring} />}
200
+ {m === "login" ? "Sign In" : "Sign Up"}
201
+ </button>
202
+ ))}
203
+ </div>
204
+ <AnimatePresence mode="wait" initial={false}>
205
+ <motion.form
206
+ key={mode + step}
207
+ onSubmit={onSubmit}
208
+ initial={{ opacity: 0, x: isLogin ? -20 : 20 }}
209
+ animate={{ opacity: 1, x: 0 }}
210
+ exit={{ opacity: 0, x: isLogin ? 20 : -20 }}
211
+ transition={{ duration: 0.2 }}
212
+ >
213
+ {step === "form" ? (
214
+ <>
215
+ <h1 className="as-title">{isLogin ? "Welcome back" : "Sign Up"}</h1>
216
+ <p className="as-sub">{isLogin ? "Sign in to continue." : "Create your account in seconds."}</p>
217
+ {!isLogin && SHOW_NAME && (
218
+ <Field icon={ICONS.user} valid={v.name.trim().length > 1} name="name" required placeholder="Full name" autoComplete="name" value={v.name} onChange={set("name")} />
219
+ )}
220
+ <Field icon={ICONS.mail} valid={emailOk} name="email" type="email" required placeholder="Email" autoComplete="email" value={v.email} onChange={set("email")} />
221
+ {!PASSWORDLESS && (
222
+ <>
223
+ <Field icon={ICONS.lock} valid={!isLogin && rules.every((r) => r[1])} name="password" type="password" required placeholder="Password" autoComplete={isLogin ? "current-password" : "new-password"} value={v.password} onChange={set("password")} />
224
+ {!isLogin && (
225
+ <ul className="as-rules">
226
+ {rules.map(([t, ok]) => <li key={t} className={ok ? "on" : ""}>{ok ? "✓" : "•"} {t}</li>)}
227
+ </ul>
228
+ )}
229
+ {!isLogin && CONFIRM_PASSWORD && (
230
+ <Field icon={ICONS.lock} valid={v.confirm.length > 0 && v.confirm === v.password} name="confirm" type="password" required placeholder="Re-type password" autoComplete="new-password" value={v.confirm} onChange={set("confirm")} />
231
+ )}
232
+ </>
233
+ )}
234
+ {error && <p role="alert" className="as-err">{error}</p>}
235
+ <button type="submit" disabled={loading} className="as-btn">
236
+ {loading ? "Please wait…" : PASSWORDLESS ? (METHOD === "otp" ? "Send code" : "Send magic link") : isLogin ? "Sign In" : "Sign Up"}
237
+ </button>
238
+ </>
239
+ ) : (
240
+ <>
241
+ <h1 className="as-title">{METHOD === "otp" ? "Enter your code" : "Check your email"}</h1>
242
+ <p className="as-sub">We sent {METHOD === "otp" ? "a 6-digit code" : "a magic link"} to {v.email}.</p>
243
+ {METHOD === "otp" && (
244
+ <Field icon={ICONS.lock} valid={code.length === 6} name="code" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} required placeholder="123456" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} />
245
+ )}
246
+ {error && <p role="alert" className="as-err">{error}</p>}
247
+ {METHOD === "otp" && (
248
+ <button type="submit" disabled={loading || code.length !== 6} className="as-btn">
249
+ {loading ? "Please wait…" : "Verify"}
250
+ </button>
251
+ )}
252
+ <button type="button" className="as-link" disabled={left > 0 || loading} onClick={resend}>
253
+ {left > 0 ? "Resend in " + left + "s" : METHOD === "otp" ? "Resend code" : "Resend link"}
254
+ </button>
255
+ <button type="button" className="as-link" onClick={() => { setStep("form"); setCode(""); setError(""); setLeft(0); }}>
256
+ Use a different email
257
+ </button>
258
+ </>
259
+ )}
260
+ {step === "form" && PROVIDERS.length > 0 && (
261
+ <div className="as-social">
262
+ {PROVIDERS.map((p) => (
263
+ <button key={p} type="button" className="as-sbtn" disabled={loading} onClick={() => run(() => social(p))}>
264
+ {BRANDS[p].svg}
265
+ {BRANDS[p].label}
266
+ </button>
267
+ ))}
268
+ </div>
269
+ )}
270
+ </motion.form>
271
+ </AnimatePresence>
272
+ </motion.div>
273
+ );
274
+
275
+ const heroPanel = (
276
+ <motion.div
277
+ layout="position"
278
+ transition={spring}
279
+ className={"as-hero" + (HERO_IMAGE ? " as-img" : "")}
280
+ style={{ order: isLogin ? 2 : 1, backgroundImage: HERO_IMAGE ? `url(${HERO_IMAGE})` : undefined }}
281
+ >
282
+ {!HERO_IMAGE && (
283
+ <>
284
+ <div className="as-shape" style={{ width: 360, height: 360, top: -120, right: -80, transform: "rotate(18deg)" }} />
285
+ <div className="as-shape" style={{ width: 420, height: 420, bottom: -180, left: -120, transform: "rotate(-14deg)", background: "rgba(255,255,255,.1)" }} />
286
+ </>
287
+ )}
288
+ <div className="as-hcard" style={{ top: 60, left: 40, width: 190 }}>
289
+ <div style={{ opacity: 0.6, fontSize: 12 }}>Secure</div>
290
+ <strong style={{ fontSize: 20 }}>Your data, your rules</strong>
291
+ </div>
292
+ <AnimatePresence mode="wait" initial={false}>
293
+ <motion.div key={mode} className="as-htext" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.2 }}>
294
+ <h2>{isLogin ? "Good to see you again" : "Join in a few clicks"}</h2>
295
+ <p>{isLogin ? "Pick up right where you left off." : "One account for everything you need."}</p>
296
+ </motion.div>
297
+ </AnimatePresence>
298
+ </motion.div>
299
+ );
300
+
301
+ const card = (
302
+ <motion.div layout transition={spring} className={"as-card" + (split ? " as-split" : "")}>
303
+ {LAYOUT === "modal" && <button type="button" className="as-close" aria-label="Close" onClick={close}>×</button>}
304
+ {formPanel}
305
+ {split && heroPanel}
306
+ </motion.div>
307
+ );
308
+
309
+ if (LAYOUT === "modal") {
310
+ return (
311
+ <div className={"as-vars as-s-" + STYLE} style={{ display: "contents" }}>
312
+ <style>{CSS}</style>
313
+ {typeof trigger === "string" ? (
314
+ <button type="button" className="as-btn as-trigger" onClick={() => setInner(true)}>{trigger}</button>
315
+ ) : trigger !== undefined ? (
316
+ <span role="presentation" onClick={() => setInner(true)}>{trigger}</span>
317
+ ) : null}
318
+ <AnimatePresence>
319
+ {isOpen && (
320
+ <motion.div
321
+ className="as-overlay"
322
+ role="dialog"
323
+ aria-modal="true"
324
+ initial={{ opacity: 0 }}
325
+ animate={{ opacity: 1 }}
326
+ exit={{ opacity: 0 }}
327
+ onMouseDown={(e) => { if (e.target === e.currentTarget) close(); }}
328
+ >
329
+ {card}
330
+ </motion.div>
331
+ )}
332
+ </AnimatePresence>
333
+ </div>
334
+ );
335
+ }
336
+
337
+ return (
338
+ <div className={"as-root as-s-" + STYLE + (LAYOUT === "canvas" ? " as-canvas" : LAYOUT === "glass" ? " as-glass" : "")} style={LAYOUT === "canvas" && HERO_IMAGE ? { backgroundImage: `linear-gradient(rgba(0,0,0,.45),rgba(0,0,0,.45)),url(${HERO_IMAGE})` } : undefined}>
339
+ <style>{CSS}</style>
340
+ {card}
341
+ </div>
342
+ );
343
+ }