authica 1.2.0 → 2.0.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/README.md +26 -5
- package/bin/ast-tools.js +193 -0
- package/bin/cli.js +196 -9
- package/bin/preview.js +203 -0
- package/package.json +13 -4
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
# ⚡
|
|
3
|
+
# ⚡authica 🔏
|
|
4
4
|
|
|
5
5
|
**Zero-config CLI that inspects your design tokens and injects polished, animated authentication routes into React & Next.js apps in seconds.**
|
|
6
6
|
|
|
7
|
-
[](https://www.npmjs.com/package/authica)
|
|
8
|
+
[](https://www.npmjs.com/package/authica)
|
|
9
9
|
[](https://opensource.org/licenses/MIT)
|
|
10
10
|
[](http://makeapullrequest.com)
|
|
11
11
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
```
|
|
22
22
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
23
|
-
│ ⚡ authica
|
|
23
|
+
│ ⚡ authica v2.0.0 │
|
|
24
24
|
├─────────────────────────────────────────────────────────────────────────────┤
|
|
25
25
|
│ │
|
|
26
26
|
│ ? Global CSS path? (src/index.css) │
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* **🎨 5 Layout Frameworks:** Choose between `split`, `centered`, `canvas`, `modal`, and `glass`.
|
|
48
48
|
* **✨ 3 Aesthetic Presets:** Toggle between `default` (Shadcn-esque), `brutalist` (hard shadows, 2px borders), and `glassmorphism` (frosted blur).
|
|
49
49
|
* **🔑 Pure Inline SVG OAuth:** Built-in crisp vector logos for **Google**, **GitHub**, **Apple**, **Discord**, and **Facebook**—no extra icon packages required.
|
|
50
|
+
* **🧪 Opt-in tooling:** AST route insertion with regex fallback, an advisory token-storage scan, Next.js middleware starters, and an isolated local preview studio.
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
|
|
@@ -98,6 +99,27 @@ Skip prompts by passing flags directly:
|
|
|
98
99
|
| **Supabase** | `--auth=supabase` | `@supabase/supabase-js` client initialization, `signInWithPassword`, `signUp`, and OAuth redirects. |
|
|
99
100
|
| **Clerk** | `--auth=clerk` | `@clerk/react` or `@clerk/nextjs` hooks, auto-created `/sso-callback` redirect route. |
|
|
100
101
|
| **Next-Auth** | `--auth=next-auth` | `signIn("credentials")` and provider mapping for Auth.js. |
|
|
102
|
+
| **WebAuthn starter** | `--auth=webauthn` | Adds passkey sign-in/registration browser calls; requires your own verified server endpoints and credential storage. |
|
|
103
|
+
|
|
104
|
+
### Opt-in developer tools
|
|
105
|
+
|
|
106
|
+
These features do not change the default `npx authica` generation path:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
npx authica --ast-inject --yes
|
|
110
|
+
npx authica --auth=clerk --middleware --yes
|
|
111
|
+
npx authica --scan-security --yes
|
|
112
|
+
npx authica audit
|
|
113
|
+
npx authica preview
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
* `--ast-inject` enables Recast/Babel route edits for Vite + React Router. If parsing fails, Authica tries its legacy injector; if it cannot place the route safely, it prints manual steps and leaves the router untouched. Next.js uses file-system routes, so there is no entry file to edit.
|
|
117
|
+
* `--middleware` is Next.js-only and requires `--auth=supabase`, `--auth=clerk`, or `--auth=next-auth`. It protects `/dashboard`; existing middleware is backed up when `--force` is used. Supabase middleware also needs `@supabase/ssr`; NextAuth config should set `pages.signIn` to `/login` too.
|
|
118
|
+
* `--scan-security` and `authica audit` perform a read-only heuristic scan for likely auth-token writes to `localStorage`. Findings are advisory and may be incomplete.
|
|
119
|
+
* `--auth=webauthn` installs `@simplewebauthn/browser` in the target app unless `--no-install` is passed. Implement POST `/api/generate-options` and `/api/verify` on the server, including per-session challenge storage, origin/RP validation, credential persistence, and mode-aware registration/authentication verification. This starter is not a complete authentication backend and does not configure OAuth.
|
|
120
|
+
* `authica preview` serves a local-only visual preview at `http://localhost:4321`; it does not install dependencies or write to the project.
|
|
121
|
+
|
|
122
|
+
The CLI also accepts the existing `--layout`, `--style`, `--auth`, `--providers`, `--css`, and `--yes` options in non-interactive use. The Web Studio reference is [authica.dev](https://authica.dev).
|
|
101
123
|
|
|
102
124
|
|
|
103
125
|
## ⚙️ Configuration (`.authicarc`)
|
|
@@ -128,4 +150,3 @@ Distributed under the MIT License. See [`LICENSE`](https://www.google.com/search
|
|
|
128
150
|
Made with ❤️ by [Sachin Mehta](https://github.com/AEGON247)
|
|
129
151
|
|
|
130
152
|
[★ Star on GitHub](https://www.google.com/search?q=https://github.com/AEGON247/authica)
|
|
131
|
-
|
package/bin/ast-tools.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import babelParser from "@babel/parser";
|
|
4
|
+
import recast from "recast";
|
|
5
|
+
import recastBabelTsParser from "recast/parsers/babel-ts.js";
|
|
6
|
+
|
|
7
|
+
const traverse = (node, visit) => {
|
|
8
|
+
if (!node || typeof node !== "object") return;
|
|
9
|
+
if (Array.isArray(node)) {
|
|
10
|
+
for (const item of node) traverse(item, visit);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (typeof node.type === "string") visit(node);
|
|
14
|
+
for (const [key, value] of Object.entries(node)) {
|
|
15
|
+
if (key !== "loc" && key !== "tokens" && key !== "comments" && key !== "extra") traverse(value, visit);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function insertViteRoute(source, importPath, routerPackage = "react-router-dom") {
|
|
20
|
+
const ast = recast.parse(source, { parser: recastBabelTsParser });
|
|
21
|
+
const lineStarts = [0];
|
|
22
|
+
for (let i = 0; i < source.length; i++) if (source[i] === "\n") lineStarts.push(i + 1);
|
|
23
|
+
const offset = (position) => lineStarts[position.line - 1] + position.column;
|
|
24
|
+
let routeContainer = null;
|
|
25
|
+
let routeKind = "";
|
|
26
|
+
let hasLogin = false;
|
|
27
|
+
let hasRouteImport = false;
|
|
28
|
+
|
|
29
|
+
traverse(ast, (node) => {
|
|
30
|
+
if (node.type === "ImportDeclaration" && node.source.value === routerPackage) {
|
|
31
|
+
hasRouteImport ||= node.specifiers.some((specifier) =>
|
|
32
|
+
specifier.type === "ImportSpecifier" &&
|
|
33
|
+
(specifier.imported.name ?? specifier.imported.value) === "Route" &&
|
|
34
|
+
specifier.local.name === "Route",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (node.type === "JSXElement" && node.openingElement.name.type === "JSXIdentifier") {
|
|
38
|
+
if (node.openingElement.name.name === "Route") {
|
|
39
|
+
const pathAttr = node.openingElement.attributes.find((a) => a.type === "JSXAttribute" && a.name.name === "path");
|
|
40
|
+
if (pathAttr?.value?.value === "/login") hasLogin = true;
|
|
41
|
+
}
|
|
42
|
+
if (node.openingElement.name.name === "Routes") {
|
|
43
|
+
routeContainer = node;
|
|
44
|
+
routeKind = "jsx";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier" && /create(?:Browser|Hash|Memory)Router/.test(node.callee.name)) {
|
|
48
|
+
if (node.arguments[0]?.type === "ArrayExpression" && !routeContainer) {
|
|
49
|
+
routeContainer = node.arguments[0];
|
|
50
|
+
routeKind = "object";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!routeContainer) throw new Error("No supported <Routes> or create*Router([...]) route container found.");
|
|
56
|
+
if (routeKind === "object") {
|
|
57
|
+
hasLogin = routeContainer.elements.some((entry) => entry.type === "ObjectExpression" &&
|
|
58
|
+
entry.properties.some((property) => property.type === "ObjectProperty" &&
|
|
59
|
+
(property.key.name ?? property.key.value) === "path" &&
|
|
60
|
+
property.value.type === "StringLiteral" && property.value.value === "/login"));
|
|
61
|
+
}
|
|
62
|
+
if (hasLogin) return { code: source, alreadyWired: true };
|
|
63
|
+
|
|
64
|
+
const edits = [];
|
|
65
|
+
let routeSnippet;
|
|
66
|
+
if (routeKind === "jsx") {
|
|
67
|
+
routeSnippet = '<Route path="/login" element={<Authica />} />';
|
|
68
|
+
if (routeContainer.openingElement.selfClosing) {
|
|
69
|
+
const openingEnd = offset(routeContainer.openingElement.loc.end);
|
|
70
|
+
edits.push({
|
|
71
|
+
start: openingEnd - 2,
|
|
72
|
+
end: openingEnd,
|
|
73
|
+
text: `>${routeSnippet}</${routeContainer.openingElement.name.name}>`,
|
|
74
|
+
});
|
|
75
|
+
} else {
|
|
76
|
+
const closeStart = offset(routeContainer.closingElement.loc.start);
|
|
77
|
+
const lineStart = source.lastIndexOf("\n", closeStart) + 1;
|
|
78
|
+
const closeIndent = source.slice(lineStart, closeStart);
|
|
79
|
+
if (closeIndent.trim() === "") {
|
|
80
|
+
edits.push({ start: lineStart, end: lineStart, text: `${closeIndent} ${routeSnippet}\n` });
|
|
81
|
+
} else {
|
|
82
|
+
const openLineStart = source.lastIndexOf("\n", routeContainer.openingElement.start) + 1;
|
|
83
|
+
const indent = source.slice(openLineStart, routeContainer.openingElement.start).match(/^[\t ]*/)?.[0] ?? "";
|
|
84
|
+
edits.push({ start: closeStart, end: closeStart, text: `\n${indent} ${routeSnippet}\n${indent}` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
routeSnippet = '{ path: "/login", element: <Authica /> }';
|
|
89
|
+
const close = offset(routeContainer.loc.end) - 1;
|
|
90
|
+
if (routeContainer.elements.length) {
|
|
91
|
+
const last = routeContainer.elements.at(-1);
|
|
92
|
+
const lastStart = offset(last.loc.start);
|
|
93
|
+
const lastEnd = offset(last.loc.end);
|
|
94
|
+
const lastLineStart = source.lastIndexOf("\n", lastStart) + 1;
|
|
95
|
+
const indent = source.slice(lastLineStart, lastStart).match(/^[\t ]*/)?.[0] ?? " ";
|
|
96
|
+
const comma = source.indexOf(",", lastEnd);
|
|
97
|
+
if (comma >= 0 && comma < close) {
|
|
98
|
+
edits.push({ start: comma + 1, end: comma + 1, text: `\n${indent}${routeSnippet},` });
|
|
99
|
+
} else {
|
|
100
|
+
edits.push({ start: lastEnd, end: lastEnd, text: `,\n${indent}${routeSnippet}` });
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
const open = offset(routeContainer.loc.start);
|
|
104
|
+
const lineStart = source.lastIndexOf("\n", open) + 1;
|
|
105
|
+
const baseIndent = source.slice(lineStart, open).match(/^[\t ]*/)?.[0] ?? "";
|
|
106
|
+
edits.push({ start: open + 1, end: open + 1, text: `\n${baseIndent} ${routeSnippet}\n${baseIndent}` });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const lastImport = [...ast.program.body].reverse().find((node) => node.type === "ImportDeclaration");
|
|
111
|
+
const importPos = lastImport ? offset(lastImport.loc.end) : 0;
|
|
112
|
+
const importText = `${lastImport ? "\n" : ""}import Authica from ${JSON.stringify(importPath)};${routeKind === "jsx" && !hasRouteImport ? `\nimport { Route } from "${routerPackage}";` : ""}\n`;
|
|
113
|
+
edits.push({ start: importPos, end: importPos, text: importText });
|
|
114
|
+
let code = source;
|
|
115
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
116
|
+
code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
|
|
117
|
+
}
|
|
118
|
+
return { code, alreadyWired: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const sourceExtensions = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
|
|
122
|
+
const ignoredDirectories = new Set([
|
|
123
|
+
"node_modules", ".git", ".next", ".turbo", ".vercel", ".cache", "dist", "build", "out", "coverage",
|
|
124
|
+
"vendor", "storybook-static", "test-results", "playwright-report",
|
|
125
|
+
]);
|
|
126
|
+
const tokenKey = /(?:token|auth|session|jwt|access|refresh)/i;
|
|
127
|
+
|
|
128
|
+
function sourceFiles(root) {
|
|
129
|
+
const found = [];
|
|
130
|
+
const errors = [];
|
|
131
|
+
const walk = (dir) => {
|
|
132
|
+
let entries;
|
|
133
|
+
try {
|
|
134
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
135
|
+
} catch (error) {
|
|
136
|
+
errors.push(`${path.relative(root, dir) || "."}: ${error.message}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
if (!ignoredDirectories.has(entry.name)) walk(path.join(dir, entry.name));
|
|
142
|
+
} else if (sourceExtensions.has(path.extname(entry.name))) {
|
|
143
|
+
found.push(path.join(dir, entry.name));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
walk(root);
|
|
148
|
+
return { files: found, errors };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function scanLocalStorage(root) {
|
|
152
|
+
const findings = [];
|
|
153
|
+
const { files, errors } = sourceFiles(root);
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
let ast;
|
|
156
|
+
try {
|
|
157
|
+
ast = babelParser.parse(fs.readFileSync(file, "utf8"), {
|
|
158
|
+
sourceType: "unambiguous",
|
|
159
|
+
plugins: ["jsx", "typescript", "decorators-legacy", "importAttributes"],
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
errors.push(`${path.relative(root, file)}: ${error.message}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
traverse(ast, (node) => {
|
|
166
|
+
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
|
|
167
|
+
const { object, property, computed } = node.callee;
|
|
168
|
+
const member = computed ? node.callee.property.value : property.name;
|
|
169
|
+
if (object.type !== "Identifier" || object.name !== "localStorage" || member !== "setItem") return;
|
|
170
|
+
const key = node.arguments[0];
|
|
171
|
+
if (key && (key.type === "StringLiteral" || key.type === "TemplateLiteral") && tokenKey.test(key.value ?? key.quasis?.[0]?.value?.cooked ?? "")) {
|
|
172
|
+
findings.push({ file: path.relative(root, file), line: node.loc?.start.line ?? 1, key: key.value ?? key.quasis[0].value.cooked });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return { findings, errors };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function printAudit(root) {
|
|
180
|
+
const { findings, errors } = scanLocalStorage(root);
|
|
181
|
+
console.log("\nAuthica advisory security scan (read-only)");
|
|
182
|
+
if (!findings.length) console.log("No likely auth-token localStorage writes found.");
|
|
183
|
+
for (const finding of findings) {
|
|
184
|
+
console.warn(`⚠ ${finding.file}:${finding.line} stores "${finding.key}" in localStorage. Consider a server-managed, httpOnly cookie where appropriate.`);
|
|
185
|
+
}
|
|
186
|
+
if (errors.length) {
|
|
187
|
+
console.warn(`Skipped ${errors.length} file(s) or directories that could not be read or parsed; findings may be incomplete.`);
|
|
188
|
+
for (const error of errors.slice(0, 10)) console.warn(` ${error}`);
|
|
189
|
+
if (errors.length > 10) console.warn(` ...and ${errors.length - 10} more.`);
|
|
190
|
+
}
|
|
191
|
+
console.log("This heuristic scan is advisory and does not prove a vulnerability or guarantee coverage.");
|
|
192
|
+
return findings.length;
|
|
193
|
+
}
|
package/bin/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import readline from "node:readline/promises";
|
|
7
7
|
|
|
@@ -9,6 +9,14 @@ const args = process.argv.slice(2);
|
|
|
9
9
|
const created = [], modified = [], steps = [];
|
|
10
10
|
let themeNote = "", rcNote = "", extraRoutes = [];
|
|
11
11
|
const flag = (n) => args.includes(`--${n}`);
|
|
12
|
+
const cliPackage = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
13
|
+
const cliVersion = cliPackage.version;
|
|
14
|
+
if (args[0] === "preview") {
|
|
15
|
+
await import("./preview.js");
|
|
16
|
+
} else if (args[0] === "audit") {
|
|
17
|
+
const { printAudit } = await import("./ast-tools.js");
|
|
18
|
+
printAudit(process.cwd());
|
|
19
|
+
} else {
|
|
12
20
|
let rc = {};
|
|
13
21
|
const hasOpt = (n) => args.some((a) => a.startsWith(`--${n}=`)) || rc[n] !== undefined;
|
|
14
22
|
const opt = (n, d) => {
|
|
@@ -18,13 +26,49 @@ const opt = (n, d) => {
|
|
|
18
26
|
return v === undefined ? d : Array.isArray(v) ? v.join(",") : String(v);
|
|
19
27
|
};
|
|
20
28
|
if (flag("help") || flag("h")) {
|
|
21
|
-
|
|
29
|
+
const c = {
|
|
30
|
+
reset: "\u001b[0m",
|
|
31
|
+
violet: "\u001b[38;5;141m",
|
|
32
|
+
cyan: "\u001b[36m",
|
|
33
|
+
green: "\u001b[32m",
|
|
34
|
+
yellow: "\u001b[33m",
|
|
35
|
+
muted: "\u001b[90m",
|
|
36
|
+
bold: "\u001b[1m",
|
|
37
|
+
};
|
|
38
|
+
const heading = (text) => `${c.violet}${c.bold}${text}${c.reset}`;
|
|
39
|
+
console.log(`${c.violet}${c.bold} ⚡ AUTHICA${c.reset} ${c.muted}Authentication UI generator${c.reset}`);
|
|
40
|
+
console.log(` ${c.cyan}[ NPM v${cliVersion} ]${c.reset} ${c.green}[ License: ${cliPackage.license} ]${c.reset}\n`);
|
|
41
|
+
console.log(`${heading("USAGE")}`);
|
|
42
|
+
console.log(" npx authica [options]\n");
|
|
43
|
+
console.log(`${heading("SUBCOMMANDS")}`);
|
|
44
|
+
console.log(` ${c.yellow}authica audit${c.reset} Advisory AppSec scan for likely auth tokens in localStorage`);
|
|
45
|
+
console.log(` ${c.yellow}authica preview${c.reset} Open the local visual studio at http://localhost:4321\n`);
|
|
46
|
+
console.log(`${heading("GENERATOR FLAGS")}`);
|
|
47
|
+
console.log(" --layout=<name> split | centered | canvas | modal | glass");
|
|
48
|
+
console.log(" --style=<name> default | brutalist | glassmorphism");
|
|
49
|
+
console.log(" --auth=<provider> none | supabase | next-auth | clerk | webauthn");
|
|
50
|
+
console.log(" --providers=<list> Comma-separated OAuth providers");
|
|
51
|
+
console.log(" --css=<file> Global CSS file used for theme tokens");
|
|
52
|
+
console.log(" --ast-inject Opt in to AST route insertion (Vite + React Router)");
|
|
53
|
+
console.log(" --middleware Opt in to Next.js provider middleware generation");
|
|
54
|
+
console.log(" --scan-security Run the advisory scan before generation");
|
|
55
|
+
console.log(" --force Overwrite generated files (keeps backups)");
|
|
56
|
+
console.log(" --yes --no-install Skip prompts and dependency installation\n");
|
|
57
|
+
console.log(`${heading("QUICKSTART EXAMPLES")} ${c.muted}(bash)${c.reset}`);
|
|
58
|
+
console.log(` ${c.green}npx authica${c.reset}`);
|
|
59
|
+
console.log(` ${c.green}npx authica --layout=glass --style=brutalist --auth=supabase --providers=google,github --yes${c.reset}`);
|
|
60
|
+
console.log(` ${c.green}npx authica --auth=clerk --middleware --ast-inject --yes${c.reset}`);
|
|
22
61
|
process.exit(0);
|
|
23
62
|
}
|
|
24
63
|
const fail = (m) => { console.error(`✖ ${m}`); process.exit(1); };
|
|
25
64
|
const cwd = process.cwd();
|
|
26
65
|
const p = (...x) => path.join(cwd, ...x);
|
|
27
66
|
const ex = (f) => fs.existsSync(p(f));
|
|
67
|
+
const enabled = (name) => {
|
|
68
|
+
const configName = { "ast-inject": "astInject", "scan-security": "scanSecurity" }[name] || name;
|
|
69
|
+
return flag(name) || rc[configName] === true || opt(name, "false") === "true";
|
|
70
|
+
};
|
|
71
|
+
console.log(`authica v${cliVersion} · Web Studio: https://authica.dev`);
|
|
28
72
|
|
|
29
73
|
// ---- team config (.authicarc / .authicarc.json); CLI flags win over it
|
|
30
74
|
const rcFile = [".authicarc", ".authicarc.json"].find(ex);
|
|
@@ -32,13 +76,13 @@ if (rcFile) {
|
|
|
32
76
|
try { rc = JSON.parse(fs.readFileSync(p(rcFile), "utf8")); }
|
|
33
77
|
catch (e) { fail(`${rcFile} is not valid JSON: ${e.message}`); }
|
|
34
78
|
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"];
|
|
79
|
+
const known = ["auth", "layout", "style", "method", "css", "hero", "entry", "providers", "name", "confirm", "social", "astInject", "middleware", "scanSecurity"];
|
|
36
80
|
const unknown = Object.keys(rc).filter((k) => !known.includes(k));
|
|
37
81
|
if (unknown.length) console.warn(`⚠ ${rcFile}: ignoring unknown keys: ${unknown.join(", ")}`);
|
|
38
82
|
rcNote = rcFile;
|
|
39
83
|
}
|
|
40
84
|
const auth = opt("auth", "none");
|
|
41
|
-
if (!["none", "supabase", "next-auth", "clerk"].includes(auth)) fail(`Unknown auth "${auth}"`);
|
|
85
|
+
if (!["none", "supabase", "next-auth", "clerk", "webauthn"].includes(auth)) fail(`Unknown auth "${auth}"`);
|
|
42
86
|
if (!ex("package.json")) fail("No package.json found. Run this in your project root.");
|
|
43
87
|
const pkg = JSON.parse(fs.readFileSync(p("package.json"), "utf8"));
|
|
44
88
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
@@ -46,10 +90,17 @@ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
|
46
90
|
// ---- detect stack
|
|
47
91
|
const isNext = !!deps.next;
|
|
48
92
|
const isVite = !isNext && !!deps.vite && !!(deps["react-router-dom"] || deps["react-router"]);
|
|
93
|
+
const routerPackage = deps["react-router-dom"] ? "react-router-dom" : "react-router";
|
|
49
94
|
if (!isNext && !isVite) fail("Unsupported project: need Next.js, or Vite + react-router(-dom).");
|
|
50
95
|
if (!deps.react) fail("React not found in package.json.");
|
|
51
96
|
if (!ex("tsconfig.json")) fail("TypeScript project required (tsconfig.json not found).");
|
|
52
97
|
if (auth === "next-auth" && !isNext) fail("--auth=next-auth requires Next.js.");
|
|
98
|
+
if (enabled("middleware") && !isNext) fail("--middleware requires a Next.js project.");
|
|
99
|
+
if (enabled("middleware") && !["supabase", "clerk", "next-auth"].includes(auth)) fail("--middleware requires --auth=supabase, --auth=clerk, or --auth=next-auth.");
|
|
100
|
+
if (enabled("ast-inject") && isNext) console.log("Next.js App Router uses file-system routes; no route-entry AST edit is needed.");
|
|
101
|
+
if (auth === "webauthn" && (flag("otp") || flag("magic-link") || opt("method", "password") !== "password")) fail("--auth=webauthn cannot be combined with --otp or --magic-link.");
|
|
102
|
+
const middlewareFile = enabled("middleware") ? (ex("src/app") ? "src/middleware.ts" : "middleware.ts") : "";
|
|
103
|
+
if (middlewareFile && ex(middlewareFile) && !flag("force")) fail(`${middlewareFile} already exists. Use --force to overwrite.`);
|
|
53
104
|
|
|
54
105
|
if (!deps.tailwindcss && !deps["@tailwindcss/vite"] && !deps["@tailwindcss/postcss"])
|
|
55
106
|
console.warn("⚠ Tailwind CSS not detected; using self-contained CSS fallbacks.");
|
|
@@ -62,6 +113,33 @@ const askBool = async (q, def) => {
|
|
|
62
113
|
const a = (await ask(`${q} [y/n]`, def ? "y" : "n")).toLowerCase();
|
|
63
114
|
return a.startsWith("y");
|
|
64
115
|
};
|
|
116
|
+
let studioStarted = false;
|
|
117
|
+
function launchStudio() {
|
|
118
|
+
if (!studioStarted) {
|
|
119
|
+
const previewPath = fileURLToPath(new URL("./preview.js", import.meta.url));
|
|
120
|
+
const child = spawn(process.execPath, [previewPath, "--open"], {
|
|
121
|
+
detached: true,
|
|
122
|
+
stdio: "ignore",
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
});
|
|
125
|
+
child.on("error", (error) => console.error(`Could not launch the local visual studio: ${error.message}`));
|
|
126
|
+
child.unref();
|
|
127
|
+
studioStarted = true;
|
|
128
|
+
}
|
|
129
|
+
console.log("Opening the local visual studio at http://localhost:4321; return here to continue.");
|
|
130
|
+
}
|
|
131
|
+
async function askWithStudio(q, def) {
|
|
132
|
+
while (true) {
|
|
133
|
+
const answer = await ask(q, def);
|
|
134
|
+
if (answer.trim().toLowerCase() !== "more") return answer;
|
|
135
|
+
launchStudio();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (enabled("scan-security")) {
|
|
140
|
+
const { printAudit } = await import("./ast-tools.js");
|
|
141
|
+
printAudit(cwd);
|
|
142
|
+
}
|
|
65
143
|
|
|
66
144
|
// ---- image path normalizer
|
|
67
145
|
function processHeroImage(inputPath) {
|
|
@@ -130,9 +208,9 @@ map.CARD = tok(["card", "background"], "#ffffff");
|
|
|
130
208
|
map.CARDFG = tok(["card-foreground", "foreground"], "#0a0a0a");
|
|
131
209
|
|
|
132
210
|
// ---- layout / fields / hero choices
|
|
133
|
-
let layout = opt("layout", "") || (await
|
|
211
|
+
let layout = opt("layout", "") || (await askWithStudio("Layout:\n split [|====] Modern 2-column hero layout\n centered [ --- ] Floating conversion card\n canvas [.....] Gradient background canvas\n modal [-----] Backdrop-blur overlay dialog\n glass [ ~~~ ] Translucent frosted card\n more [ 🌐 Launch Visual Web Studio ]\nChoose layout:", "split"));
|
|
134
212
|
if (!["split", "centered", "canvas", "modal", "glass"].includes(layout)) { console.warn(`⚠ Unknown layout "${layout}", using split.`); layout = "split"; }
|
|
135
|
-
let style = opt("style", "") || (await
|
|
213
|
+
let style = opt("style", "") || (await askWithStudio("Visual style:\n default [neutral] Shadcn-like\n brutalist [bold] Hard borders and shadows\n glassmorphism [frosted] Blur and glow\n more [ 🌐 Launch Visual Web Studio ]\nChoose style:", "default"));
|
|
136
214
|
if (!["default", "brutalist", "glassmorphism"].includes(style)) { console.warn(`⚠ Unknown style "${style}", using default.`); style = "default"; }
|
|
137
215
|
let method = flag("otp") ? "otp" : flag("magic-link") ? "magic" : opt("method", "") || (await ask("Sign-in method: password, otp or magic-link?", "password"));
|
|
138
216
|
if (method === "magic-link") method = "magic";
|
|
@@ -149,6 +227,10 @@ if (!flag("no-social") && rc.social !== false) {
|
|
|
149
227
|
if (bad.length) console.warn(`⚠ Ignoring unknown providers: ${bad.join(", ")}`);
|
|
150
228
|
providers = [...new Set(providers.filter((x) => ALL.includes(x)))];
|
|
151
229
|
}
|
|
230
|
+
if (auth === "webauthn" && providers.length) {
|
|
231
|
+
steps.push("WebAuthn starter does not configure OAuth providers; OAuth requires a provider-specific backend and route.");
|
|
232
|
+
providers = [];
|
|
233
|
+
}
|
|
152
234
|
const rawHero = !["split", "canvas"].includes(layout) ? "" : opt("hero", "") || (hasOpt("hero") ? "" : await ask("Hero image path or URL (blank = built-in artwork)?", ""));
|
|
153
235
|
const hero = processHeroImage(rawHero);
|
|
154
236
|
|
|
@@ -240,6 +322,16 @@ function useAuth() {
|
|
|
240
322
|
}`,
|
|
241
323
|
pkgs: [isNext ? "@clerk/nextjs" : "@clerk/react"],
|
|
242
324
|
},
|
|
325
|
+
webauthn: {
|
|
326
|
+
imports: `import { startAuthentication, startRegistration } from "@simplewebauthn/browser";\n`,
|
|
327
|
+
hook: `function useAuth() {
|
|
328
|
+
return {
|
|
329
|
+
submit: async (_mode: Mode, _d: Data) => { throw new Error("Use passkey sign-in."); },
|
|
330
|
+
social: async (_p: Social) => { throw new Error("Social sign-in is not configured for this WebAuthn starter."); },
|
|
331
|
+
};
|
|
332
|
+
}`,
|
|
333
|
+
pkgs: ["@simplewebauthn/browser"],
|
|
334
|
+
},
|
|
243
335
|
}[auth];
|
|
244
336
|
|
|
245
337
|
const pw = method === "password";
|
|
@@ -308,6 +400,37 @@ src = src.replace("__DIRECTIVE__", () => (isNext ? '"use client";\n\n' : ""))
|
|
|
308
400
|
.replace("__AUTH_IMPORTS__", () => hooks.imports)
|
|
309
401
|
.replace("__AUTH_HOOK__", () => hooks.hook);
|
|
310
402
|
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);
|
|
403
|
+
if (auth === "webauthn") {
|
|
404
|
+
const helper = `\n\nasync function authicaPasskey(email: string, mode: Mode) {
|
|
405
|
+
const optionsResponse = await fetch("/api/generate-options", {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: { "Content-Type": "application/json" },
|
|
408
|
+
body: JSON.stringify({ email, mode }),
|
|
409
|
+
});
|
|
410
|
+
if (!optionsResponse.ok) throw new Error("Could not start passkey sign-in.");
|
|
411
|
+
const optionsJSON = await optionsResponse.json();
|
|
412
|
+
const credential = mode === "signup"
|
|
413
|
+
? await startRegistration({ optionsJSON })
|
|
414
|
+
: await startAuthentication({ optionsJSON });
|
|
415
|
+
const verifyResponse = await fetch("/api/verify", {
|
|
416
|
+
method: "POST",
|
|
417
|
+
headers: { "Content-Type": "application/json" },
|
|
418
|
+
body: JSON.stringify({ email, mode, credential }),
|
|
419
|
+
});
|
|
420
|
+
if (!verifyResponse.ok) throw new Error("Passkey verification failed.");
|
|
421
|
+
window.location.href = "/";
|
|
422
|
+
}`;
|
|
423
|
+
src = src.replace(hooks.hook, `${hooks.hook}${helper}`);
|
|
424
|
+
src = src.replace("const PASSWORDLESS = METHOD !== \"password\";", "const PASSWORDLESS = true;");
|
|
425
|
+
src = src.replace(
|
|
426
|
+
'<button type="submit" disabled={loading} className="as-btn">',
|
|
427
|
+
'<button type="button" disabled={loading || !emailOk} onClick={() => run(() => authicaPasskey(v.email, mode))} className="as-btn">',
|
|
428
|
+
);
|
|
429
|
+
src = src.replace(
|
|
430
|
+
'{loading ? "Please wait…" : PASSWORDLESS ? (METHOD === "otp" ? "Send code" : "Send magic link") : isLogin ? "Sign In" : "Sign Up"}',
|
|
431
|
+
'{loading ? "Please wait…" : isLogin ? "Sign in with a passkey" : "Create a passkey"}',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
311
434
|
if (/__[A-Z]+__/.test(src)) fail("Unresolved template placeholder (internal error).");
|
|
312
435
|
|
|
313
436
|
// ---- target path
|
|
@@ -330,8 +453,55 @@ if (isVite) {
|
|
|
330
453
|
if (!file) manual();
|
|
331
454
|
else {
|
|
332
455
|
let c = fs.readFileSync(p(file), "utf8");
|
|
333
|
-
if (c.includes("authica")) modified.push(`${file} (already wired)`);
|
|
334
|
-
else {
|
|
456
|
+
if (c.includes("authica") || (enabled("ast-inject") && c.includes("Authica"))) modified.push(`${file} (already wired)`);
|
|
457
|
+
else if (enabled("ast-inject")) {
|
|
458
|
+
let astResult;
|
|
459
|
+
try {
|
|
460
|
+
const { insertViteRoute } = await import("./ast-tools.js");
|
|
461
|
+
astResult = insertViteRoute(c, (() => {
|
|
462
|
+
let rel = path.posix.relative(path.posix.dirname(file), "src/routes/login");
|
|
463
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
464
|
+
})(), routerPackage);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
console.warn(`⚠ AST route insertion could not safely parse ${file}: ${error.message}`);
|
|
467
|
+
}
|
|
468
|
+
if (astResult?.alreadyWired) {
|
|
469
|
+
modified.push(`${file} (already wired)`);
|
|
470
|
+
} else {
|
|
471
|
+
if (astResult) c = astResult.code;
|
|
472
|
+
else {
|
|
473
|
+
const before = c;
|
|
474
|
+
let rel = path.posix.relative(path.posix.dirname(file), "src/routes/login");
|
|
475
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
476
|
+
if (/<Routes[\s>]/.test(c)) c = c.replace(/<Routes(\s[^>]*)?>/, (m) => `${m}\n <Route path="/login" element={<Authica />} />`);
|
|
477
|
+
else c = c.replace(/(create\w*Router\(\s*\[)/, `$1\n { path: "/login", element: <Authica /> },`);
|
|
478
|
+
if (c === before) {
|
|
479
|
+
manual();
|
|
480
|
+
console.log("AST fallback found no supported route position; target router was left unchanged.");
|
|
481
|
+
c = "";
|
|
482
|
+
} else {
|
|
483
|
+
let end = 0;
|
|
484
|
+
for (const m of c.matchAll(/^import\s[^;]*?['"][^'"]+['"];?[ \t]*$/gm)) end = Math.max(end, m.index + m[0].length);
|
|
485
|
+
const relImport = path.posix.relative(path.posix.dirname(file), "src/routes/login").replace(/^(?!\.)/, "./");
|
|
486
|
+
const imp = `import Authica from "${relImport}";`;
|
|
487
|
+
c = end ? c.slice(0, end) + "\n" + imp + c.slice(end) : imp + "\n" + c;
|
|
488
|
+
const routeImportPattern = new RegExp(`import\\s*\\{[^}]*\\bRoute\\b[^}]*\\}\\s*from\\s*["']${routerPackage}["']`);
|
|
489
|
+
if (/<Routes[\s>]/.test(before) && !routeImportPattern.test(c)) {
|
|
490
|
+
const routeImport = `import { Route } from "${routerPackage}";`;
|
|
491
|
+
const importEnd = [...c.matchAll(/^import\s[^;]*?['"][^'"]+['"];?[ \t]*$/gm)].reduce((end, match) => Math.max(end, match.index + match[0].length), 0);
|
|
492
|
+
c = importEnd ? c.slice(0, importEnd) + "\n" + routeImport + c.slice(importEnd) : routeImport + "\n" + c;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (c) {
|
|
497
|
+
const dirty = spawnSync("git", ["status", "--porcelain", "--", file], { cwd, encoding: "utf8" });
|
|
498
|
+
if (dirty.status === 0 && dirty.stdout.trim()) console.warn(`⚠ ${file} has uncommitted changes.`);
|
|
499
|
+
if (!ex(file + ".bak")) fs.copyFileSync(p(file), p(file + ".bak"));
|
|
500
|
+
fs.writeFileSync(p(file), c);
|
|
501
|
+
modified.push(`${file} (+/login route, backup ${file}.bak)`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
335
505
|
let rel = path.posix.relative(path.posix.dirname(file), "src/routes/login");
|
|
336
506
|
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
337
507
|
if (/<Routes[\s>]/.test(c)) c = c.replace(/<Routes(\s[^>]*)?>/, (m) => `${m}\n <Route path="/login" element={<authica />} />`);
|
|
@@ -349,6 +519,19 @@ if (isVite) {
|
|
|
349
519
|
}
|
|
350
520
|
}
|
|
351
521
|
|
|
522
|
+
if (middlewareFile) {
|
|
523
|
+
const middleware = auth === "clerk"
|
|
524
|
+
? `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";\nimport { NextResponse } from "next/server";\n\nconst isProtectedRoute = createRouteMatcher(["/dashboard(.*)"]);\n\nexport default clerkMiddleware(async (auth, request) => {\n if (!isProtectedRoute(request)) return;\n const { userId } = await auth();\n if (!userId) return NextResponse.redirect(new URL("/login", request.url));\n});\n\nexport const config = { matcher: ["/dashboard/:path*"] };\n`
|
|
525
|
+
: auth === "supabase"
|
|
526
|
+
? `import { createServerClient } from "@supabase/ssr";\nimport { NextResponse, type NextRequest } from "next/server";\n\nexport async function middleware(request: NextRequest) {\n let response = NextResponse.next({ request });\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,\n { cookies: {\n getAll: () => request.cookies.getAll(),\n setAll: (cookies) => {\n cookies.forEach(({ name, value }) => request.cookies.set(name, value));\n response = NextResponse.next({ request });\n cookies.forEach(({ name, value, options }) => response.cookies.set(name, value, options));\n },\n } },\n );\n const { data: { user } } = await supabase.auth.getUser();\n if (request.nextUrl.pathname.startsWith("/dashboard") && !user) {\n const loginUrl = request.nextUrl.clone();\n loginUrl.pathname = "/login";\n loginUrl.searchParams.set("next", request.nextUrl.pathname);\n return NextResponse.redirect(loginUrl);\n }\n return response;\n}\n\nexport const config = { matcher: ["/dashboard/:path*"] };\n`
|
|
527
|
+
: `import { withAuth } from "next-auth/middleware";\n\nexport default withAuth({ pages: { signIn: "/login" } });\n\nexport const config = { matcher: ["/dashboard/:path*"] };\n`;
|
|
528
|
+
fs.mkdirSync(path.dirname(p(middlewareFile)), { recursive: true });
|
|
529
|
+
if (ex(middlewareFile) && !ex(middlewareFile + ".bak")) fs.copyFileSync(p(middlewareFile), p(middlewareFile + ".bak"));
|
|
530
|
+
fs.writeFileSync(p(middlewareFile), middleware);
|
|
531
|
+
created.push(middlewareFile);
|
|
532
|
+
if (auth === "supabase") steps.push("Install @supabase/ssr for the generated Supabase middleware.");
|
|
533
|
+
}
|
|
534
|
+
|
|
352
535
|
// ---- provider notices / Clerk SSO callback route
|
|
353
536
|
if (providers.length) {
|
|
354
537
|
if (auth === "clerk") {
|
|
@@ -377,7 +560,7 @@ if (method === "otp" && auth === "supabase") steps.push("Supabase email template
|
|
|
377
560
|
if (method !== "password" && auth === "next-auth") steps.push('next-auth: add an Email provider (id "email")' + (method === "otp" ? " with generateVerificationToken returning 6 digits." : "."));
|
|
378
561
|
|
|
379
562
|
// ---- dependencies
|
|
380
|
-
const need = ["framer-motion", ...hooks.pkgs].filter((d) => !deps[d]);
|
|
563
|
+
const need = ["framer-motion", ...hooks.pkgs, ...(middlewareFile && auth === "supabase" ? ["@supabase/ssr"] : [])].filter((d) => !deps[d]);
|
|
381
564
|
if (need.length && !flag("no-install")) {
|
|
382
565
|
const pm = ex("pnpm-lock.yaml") ? "pnpm" : ex("yarn.lock") ? "yarn" : ex("bun.lockb") || ex("bun.lock") ? "bun" : "npm";
|
|
383
566
|
console.log(`Installing ${need.join(", ")} with ${pm}…`);
|
|
@@ -393,11 +576,14 @@ const envVars = {
|
|
|
393
576
|
supabase: [pre + "SUPABASE_URL", pre + "SUPABASE_ANON_KEY"],
|
|
394
577
|
clerk: isNext ? ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"] : ["VITE_CLERK_PUBLISHABLE_KEY"],
|
|
395
578
|
"next-auth": ["NEXTAUTH_URL", "NEXTAUTH_SECRET"],
|
|
579
|
+
webauthn: [],
|
|
396
580
|
}[auth];
|
|
397
581
|
const next = [...steps];
|
|
398
582
|
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
583
|
if (auth === "clerk") next.push(`Wrap your app in <ClerkProvider publishableKey={...}>.`);
|
|
400
584
|
if (auth === "next-auth") next.push("OAuth client ID/secret for each provider go in your NextAuth config.");
|
|
585
|
+
if (middlewareFile && auth === "next-auth") next.push('Set `pages.signIn` to "/login" in your NextAuth config as well.');
|
|
586
|
+
if (auth === "webauthn") next.push("WebAuthn starter requires server POST endpoints /api/generate-options and /api/verify with challenge storage, origin/RP validation, credential persistence, and mode-aware registration/authentication verification.");
|
|
401
587
|
if (layout === "modal") next.push('Modal: use as the /login route, or render <authica trigger="Sign in" /> (props: open, onClose, trigger).');
|
|
402
588
|
next.push("Start your dev server and open /login.");
|
|
403
589
|
const routes = ["/login", ...extraRoutes];
|
|
@@ -437,3 +623,4 @@ const title = " authica ✔ ";
|
|
|
437
623
|
console.log("\n╭" + title + "─".repeat(W - 2 - title.length) + "╮");
|
|
438
624
|
for (const l of body) console.log("│ " + l.padEnd(inner) + " │");
|
|
439
625
|
console.log("╰" + "─".repeat(W - 2) + "╯");
|
|
626
|
+
}
|
package/bin/preview.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
const port = 4321;
|
|
5
|
+
const page = `<!doctype html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
9
|
+
<meta name="color-scheme" content="dark">
|
|
10
|
+
<title>Authica · Visual Studio</title>
|
|
11
|
+
<style>
|
|
12
|
+
:root{color-scheme:dark;--bg:#0B0B0E;--canvas:#0B0B0E;--panel:#13131A;--border:#22222E;--accent:#8B5CF6;--accent-soft:#8B5CF626;--text:#F4F2FA;--muted:#92909F;--card:#181820;--field:#101016}
|
|
13
|
+
*{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--canvas);color:var(--text);font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
14
|
+
button,input{font:inherit}button{color:inherit}.app{min-height:100vh;display:grid;grid-template-columns:248px minmax(0,1fr) 250px}
|
|
15
|
+
.sidebar,.inspector{background:var(--panel);border-color:var(--border);min-height:100vh}.sidebar{border-right:1px solid var(--border);padding:22px 14px;position:sticky;top:0;height:100vh;overflow:auto}.inspector{border-left:1px solid var(--border);padding:22px 16px}
|
|
16
|
+
.brand{display:flex;align-items:center;gap:10px;padding:0 8px 22px;font-weight:700;font-size:15px}.brand-mark{display:grid;place-items:center;width:30px;height:30px;border-radius:10px;background:linear-gradient(145deg,#a78bfa,var(--accent));box-shadow:0 0 24px #8b5cf644}
|
|
17
|
+
.brand small{display:block;color:var(--muted);font-size:11px;font-weight:500}.search{width:100%;height:38px;border:1px solid var(--border);border-radius:11px;background:#0d0d13;color:var(--text);padding:0 11px;outline:none;margin-bottom:24px}.search:focus{border-color:var(--accent)}
|
|
18
|
+
.section{margin:0 0 22px}.section-title,.panel-title{margin:0 8px 9px;color:var(--muted);font-size:10px;font-weight:700;letter-spacing:.13em;text-transform:uppercase}
|
|
19
|
+
.nav-list{display:grid;gap:4px}.nav-item{width:100%;border:1px solid transparent;border-radius:999px;background:transparent;padding:8px 11px;display:flex;align-items:center;gap:10px;text-align:left;cursor:pointer;color:#bbb9c7;transition:background .16s,border-color .16s,color .16s}
|
|
20
|
+
.nav-item:hover{background:#ffffff08;color:var(--text)}.nav-item[aria-pressed=true]{color:#e5d9ff;background:var(--accent-soft);border-color:#8b5cf640}.nav-icon{width:19px;text-align:center;color:#a6a2b3}.nav-item[aria-pressed=true] .nav-icon{color:#c4aaff}
|
|
21
|
+
.main{min-width:0;padding:24px clamp(18px,3vw,42px) 36px}.topbar{display:flex;align-items:center;gap:10px;border:1px solid var(--border);background:#121219;border-radius:14px;padding:8px 9px 8px 14px;box-shadow:0 8px 32px #0002}
|
|
22
|
+
.command{flex:1;min-width:0;overflow:auto;color:#d5c6ff;font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:nowrap}.prompt{color:#8f8c9a;margin-right:8px}
|
|
23
|
+
.copy{flex:none;border:1px solid #8b5cf64d;border-radius:9px;background:#8b5cf61a;padding:7px 11px;color:#d7c8ff;font-size:12px;cursor:pointer}.copy:hover{background:#8b5cf633}
|
|
24
|
+
.intro{display:flex;justify-content:space-between;align-items:end;gap:18px;margin:30px 0 17px}.eyebrow{color:#b79aff;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase}.intro h1{margin:5px 0 3px;font-size:25px;letter-spacing:-.04em}.intro p{margin:0;color:var(--muted);font-size:13px}
|
|
25
|
+
.preview-label{color:var(--muted);font-size:11px}.viewport{min-height:520px;position:relative;display:grid;place-items:center;overflow:hidden;border:1px solid var(--border);border-radius:18px;background:radial-gradient(ellipse at 50% 45%,#171521,#0d0d12 70%);box-shadow:0 24px 80px #0004}
|
|
26
|
+
.viewport::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.28;background-image:radial-gradient(#777084 0.6px,transparent 0.6px);background-size:22px 22px}
|
|
27
|
+
.mock{position:relative;z-index:1;width:min(760px,88%);min-height:400px;display:flex;overflow:hidden;border:1px solid #ffffff24;border-radius:20px;background:var(--card);box-shadow:0 24px 80px #0008;transition:all .22s}
|
|
28
|
+
.mock.centered,.mock.canvas,.mock.modal,.mock.glass{width:min(410px,88%);min-height:0}.mock.canvas{background:radial-gradient(ellipse at 80% 0%,#8b5cf644,#17151f 65%)}.mock.modal{border-radius:14px;box-shadow:0 30px 90px #000b,0 0 0 999px #0002}.mock.glass{background:linear-gradient(145deg,#211a35,#111721);box-shadow:0 0 55px #8b5cf622}
|
|
29
|
+
.hero{display:none;position:relative;flex:1;background:linear-gradient(145deg,#5036a0,#241c41 58%,#17171f);padding:26px}.mock.split .hero{display:flex;flex-direction:column;justify-content:space-between}
|
|
30
|
+
.hero-chip{width:max-content;border:1px solid #ffffff30;background:#ffffff12;border-radius:999px;padding:6px 10px;color:#e9ddff;font-size:10px}.hero h2{max-width:230px;margin:0;font-size:27px;line-height:1.15;letter-spacing:-.04em}.hero p{margin:8px 0 0;color:#d4cae8;font-size:12px}
|
|
31
|
+
.form{width:100%;padding:clamp(22px,4vw,36px);display:flex;flex-direction:column;justify-content:center}.mock.split .form{width:50%}.form-brand{color:#c3aaff;font-size:11px;font-weight:700;letter-spacing:.08em}.form h2{margin:12px 0 3px;font-size:23px;letter-spacing:-.04em}.form .hint{margin:0 0 20px;color:#9c99a8;font-size:12px}
|
|
32
|
+
.field-label{font-size:10px;color:#c1becc;margin:10px 0 5px}.field{height:38px;border:1px solid #353441;border-radius:9px;background:var(--field);padding:0 11px;color:#d0ced8;font-size:11px;display:flex;align-items:center}.mock.glass .field{background:#ffffff0b}
|
|
33
|
+
.submit{height:39px;margin-top:16px;border:0;border-radius:999px;background:var(--accent);color:white;font-weight:650;font-size:11px;box-shadow:0 5px 22px #8b5cf644}
|
|
34
|
+
.mock.brutalist{border:3px solid #f4f2fa;border-radius:3px;box-shadow:7px 7px 0 #000}.mock.brutalist .submit{border-radius:4px;background:#facc15;color:#17130a;box-shadow:none}.mock.glassmorphism{background:linear-gradient(145deg,#8b5cf633,#111721dd);border-color:#ffffff30;backdrop-filter:blur(16px)}.mock.glassmorphism .form{background:#ffffff08}
|
|
35
|
+
.divider{display:flex;align-items:center;gap:9px;color:#777482;font-size:9px;margin:17px 0 10px}.divider::before,.divider::after{content:"";height:1px;flex:1;background:#34323f}
|
|
36
|
+
.social{display:flex;gap:8px}.social span{height:34px;flex:1;display:grid;place-items:center;border:1px solid #353441;border-radius:9px;color:#c7c4d0;font-size:10px}
|
|
37
|
+
.inspector-heading{padding:1px 5px 20px}.inspector-heading h2{font-size:14px;margin:0 0 3px}.inspector-heading p{font-size:11px;color:var(--muted);margin:0}
|
|
38
|
+
.panel{border-top:1px solid var(--border);padding:17px 3px}.panel-title{margin:0 0 12px}.swatches{display:grid;gap:9px}.swatch-row{display:flex;align-items:center;justify-content:space-between;color:#c1becc;font-size:11px}.swatch-value{display:flex;align-items:center;gap:7px;color:var(--muted);font:10px ui-monospace,monospace}
|
|
39
|
+
.color-input{appearance:none;width:20px;height:20px;border:1px solid #ffffff30;border-radius:7px;padding:0;background:transparent;overflow:hidden;cursor:pointer}.color-input::-webkit-color-swatch-wrapper{padding:0}.color-input::-webkit-color-swatch{border:0;border-radius:6px}
|
|
40
|
+
.providers{display:grid;gap:7px}.provider{width:100%;display:flex;justify-content:space-between;align-items:center;border:1px solid var(--border);border-radius:10px;background:#0f0f15;padding:9px 10px;cursor:pointer;color:#c4c1ce;font-size:11px}.provider[aria-pressed=true]{border-color:#8b5cf655;background:#8b5cf610;color:#e2d7ff}
|
|
41
|
+
.switch{width:27px;height:16px;border-radius:999px;background:#34323e;padding:2px;transition:background .15s}.switch::after{content:"";display:block;width:12px;height:12px;border-radius:50%;background:#a09dab;transition:transform .15s}.provider[aria-pressed=true] .switch{background:var(--accent)}.provider[aria-pressed=true] .switch::after{transform:translateX(11px);background:white}
|
|
42
|
+
.empty{display:none;color:var(--muted);padding:8px;font-size:12px}.footer-note{margin-top:20px;color:#777482;font-size:10px;line-height:1.6}
|
|
43
|
+
@media(max-width:1050px){.app{grid-template-columns:220px minmax(0,1fr)}.inspector{grid-column:2;min-height:0;border-left:0;border-top:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.inspector-heading{grid-column:1/-1}.panel{align-self:start}}
|
|
44
|
+
@media(max-width:700px){.app{display:block}.sidebar{position:relative;height:auto;min-height:0;border-right:0;border-bottom:1px solid var(--border);padding:14px}.brand{padding-bottom:12px}.search{margin-bottom:13px}.section{margin-bottom:11px}.nav-list{display:flex;flex-wrap:wrap}.nav-item{width:auto;padding:7px 10px;font-size:12px}.main{padding:20px 14px}.intro{align-items:start;flex-direction:column;margin-top:22px}.viewport{min-height:450px}.mock.split{width:94%;min-height:360px}.mock.split .form{width:58%;padding:18px}.mock.split .hero{padding:18px}.hero h2{font-size:22px}.inspector{display:block;padding:18px 16px}.panel{padding:14px 3px}}
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<div class="app">
|
|
49
|
+
<aside class="sidebar" aria-label="Studio navigation">
|
|
50
|
+
<div class="brand"><span class="brand-mark">⚡</span><span>authica <small>VISUAL STUDIO</small></span></div>
|
|
51
|
+
<input id="search" class="search" type="search" placeholder="Search templates…" aria-label="Search templates">
|
|
52
|
+
<section class="section" data-section="layouts">
|
|
53
|
+
<h2 class="section-title">Layout Frameworks</h2>
|
|
54
|
+
<nav class="nav-list" aria-label="Layout Frameworks">
|
|
55
|
+
<button class="nav-item" data-layout="split" aria-pressed="true"><span class="nav-icon">◫</span>Split</button>
|
|
56
|
+
<button class="nav-item" data-layout="centered" aria-pressed="false"><span class="nav-icon">▣</span>Centered</button>
|
|
57
|
+
<button class="nav-item" data-layout="canvas" aria-pressed="false"><span class="nav-icon">▧</span>Canvas</button>
|
|
58
|
+
<button class="nav-item" data-layout="modal" aria-pressed="false"><span class="nav-icon">▢</span>Modal</button>
|
|
59
|
+
<button class="nav-item" data-layout="glass" aria-pressed="false"><span class="nav-icon">✧</span>Glass</button>
|
|
60
|
+
</nav><p class="empty" data-empty="layouts">No layouts match.</p>
|
|
61
|
+
</section>
|
|
62
|
+
<section class="section" data-section="styles">
|
|
63
|
+
<h2 class="section-title">Aesthetic Presets</h2>
|
|
64
|
+
<nav class="nav-list" aria-label="Aesthetic Presets">
|
|
65
|
+
<button class="nav-item" data-style="default" aria-pressed="true"><span class="nav-icon">◌</span>Default</button>
|
|
66
|
+
<button class="nav-item" data-style="brutalist" aria-pressed="false"><span class="nav-icon">▦</span>Brutalist</button>
|
|
67
|
+
<button class="nav-item" data-style="glassmorphism" aria-pressed="false"><span class="nav-icon">◈</span>Glassmorphism</button>
|
|
68
|
+
</nav><p class="empty" data-empty="styles">No presets match.</p>
|
|
69
|
+
</section>
|
|
70
|
+
</aside>
|
|
71
|
+
<main class="main">
|
|
72
|
+
<div class="topbar"><code class="command"><span class="prompt">$</span><span id="command">npx authica --layout=split --style=default</span></code><button class="copy" id="copy" type="button">Copy</button></div>
|
|
73
|
+
<div class="intro"><div><span class="eyebrow">Design your sign-in flow</span><h1>Authentication, styled.</h1><p>Choose a layout and preset. Your CLI command updates as you explore.</p></div><span class="preview-label">LIVE PREVIEW · LOCAL ONLY</span></div>
|
|
74
|
+
<section class="viewport" aria-label="Interactive login preview">
|
|
75
|
+
<div id="mock" class="mock split">
|
|
76
|
+
<div class="hero"><span class="hero-chip">✦ Secure by design</span><div><h2>Welcome to your next chapter.</h2><p>A sign-in experience that feels like your product.</p></div></div>
|
|
77
|
+
<div class="form"><span class="form-brand">AUTHICA</span><h2>Welcome back</h2><p class="hint">Sign in to continue to your account.</p><label class="field-label">Email address</label><div class="field">you@example.com</div><label class="field-label">Password</label><div class="field">••••••••••</div><button class="submit" type="button">Sign in</button><div class="divider">OR CONTINUE WITH</div><div class="social" id="mock-social"><span>G Google</span><span>⌘ GitHub</span></div></div>
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
</main>
|
|
81
|
+
<aside class="inspector" aria-label="Theme and provider settings">
|
|
82
|
+
<div class="inspector-heading"><h2>Quick customize</h2><p>Adjust preview-only options.</p></div>
|
|
83
|
+
<section class="panel"><h3 class="panel-title">CSS theme variables</h3><div class="swatches">
|
|
84
|
+
<label class="swatch-row">Primary accent <span class="swatch-value"><input class="color-input" type="color" data-color="accent" value="#8b5cf6"><code id="accent-value">#8B5CF6</code></span></label>
|
|
85
|
+
<label class="swatch-row">Card surface <span class="swatch-value"><input class="color-input" type="color" data-color="card" value="#181820"><code id="card-value">#181820</code></span></label>
|
|
86
|
+
<label class="swatch-row">Field surface <span class="swatch-value"><input class="color-input" type="color" data-color="field" value="#101016"><code id="field-value">#101016</code></span></label>
|
|
87
|
+
<label class="swatch-row">Canvas <span class="swatch-value"><input class="color-input" type="color" data-color="canvas" value="#0b0b0e"><code id="canvas-value">#0B0B0E</code></span></label>
|
|
88
|
+
</div></section>
|
|
89
|
+
<section class="panel"><h3 class="panel-title">OAuth providers</h3><div class="providers">
|
|
90
|
+
<button class="provider" data-provider="google" aria-pressed="true"><span>Google</span><span class="switch"></span></button>
|
|
91
|
+
<button class="provider" data-provider="github" aria-pressed="true"><span>GitHub</span><span class="switch"></span></button>
|
|
92
|
+
<button class="provider" data-provider="apple" aria-pressed="false"><span>Apple</span><span class="switch"></span></button>
|
|
93
|
+
</div><p class="footer-note">These controls are a visual preview only. Authica does not write project files in Studio.</p></section>
|
|
94
|
+
</aside>
|
|
95
|
+
</div>
|
|
96
|
+
<script>
|
|
97
|
+
(() => {
|
|
98
|
+
const layoutButtons = [...document.querySelectorAll("[data-layout]")];
|
|
99
|
+
const styleButtons = [...document.querySelectorAll("[data-style]")];
|
|
100
|
+
const search = document.querySelector("#search");
|
|
101
|
+
const mock = document.querySelector("#mock");
|
|
102
|
+
const command = document.querySelector("#command");
|
|
103
|
+
const providers = [...document.querySelectorAll("[data-provider]")];
|
|
104
|
+
let layout = "split", style = "default";
|
|
105
|
+
const selectedProviders = new Set(["google", "github"]);
|
|
106
|
+
const upper = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
107
|
+
function update() {
|
|
108
|
+
mock.className = "mock " + layout + (style === "default" ? "" : " " + style);
|
|
109
|
+
document.querySelector(".intro h1").textContent = upper(layout) + " authentication.";
|
|
110
|
+
command.textContent = "npx authica --layout=" + layout + " --style=" + style +
|
|
111
|
+
(selectedProviders.size ? " --providers=" + [...selectedProviders].join(",") : "");
|
|
112
|
+
layoutButtons.forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.layout === layout)));
|
|
113
|
+
styleButtons.forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.style === style)));
|
|
114
|
+
providers.forEach((button) => button.setAttribute("aria-pressed", String(selectedProviders.has(button.dataset.provider))));
|
|
115
|
+
const social = document.querySelector("#mock-social");
|
|
116
|
+
const names = [...selectedProviders].map(upper);
|
|
117
|
+
social.innerHTML = names.length ? names.map((name) => "<span>" + name + "</span>").join("") : "<span>Email sign-in</span>";
|
|
118
|
+
}
|
|
119
|
+
layoutButtons.forEach((button) => button.addEventListener("click", () => { layout = button.dataset.layout; update(); }));
|
|
120
|
+
styleButtons.forEach((button) => button.addEventListener("click", () => { style = button.dataset.style; update(); }));
|
|
121
|
+
providers.forEach((button) => button.addEventListener("click", () => {
|
|
122
|
+
const name = button.dataset.provider;
|
|
123
|
+
if (selectedProviders.has(name)) selectedProviders.delete(name); else selectedProviders.add(name);
|
|
124
|
+
update();
|
|
125
|
+
}));
|
|
126
|
+
search.addEventListener("input", () => {
|
|
127
|
+
const query = search.value.trim().toLowerCase();
|
|
128
|
+
for (const section of document.querySelectorAll("[data-section]")) {
|
|
129
|
+
const buttons = [...section.querySelectorAll(".nav-item")];
|
|
130
|
+
let visible = 0;
|
|
131
|
+
buttons.forEach((button) => {
|
|
132
|
+
const show = button.textContent.toLowerCase().includes(query);
|
|
133
|
+
button.hidden = !show;
|
|
134
|
+
if (show) visible++;
|
|
135
|
+
});
|
|
136
|
+
section.querySelector(".empty").style.display = visible ? "none" : "block";
|
|
137
|
+
section.querySelector(".section-title").hidden = !visible;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
document.querySelectorAll("[data-color]").forEach((input) => input.addEventListener("input", () => {
|
|
141
|
+
const name = input.dataset.color;
|
|
142
|
+
const value = input.value.toUpperCase();
|
|
143
|
+
document.documentElement.style.setProperty("--" + name, input.value);
|
|
144
|
+
document.querySelector("#" + name + "-value").textContent = value;
|
|
145
|
+
}));
|
|
146
|
+
document.querySelector("#copy").addEventListener("click", async (event) => {
|
|
147
|
+
const button = event.currentTarget;
|
|
148
|
+
try {
|
|
149
|
+
await navigator.clipboard.writeText(command.textContent);
|
|
150
|
+
button.textContent = "Copied!";
|
|
151
|
+
setTimeout(() => { button.textContent = "Copy"; }, 1400);
|
|
152
|
+
} catch {
|
|
153
|
+
const range = document.createRange();
|
|
154
|
+
range.selectNodeContents(command);
|
|
155
|
+
const selection = window.getSelection();
|
|
156
|
+
selection.removeAllRanges();
|
|
157
|
+
selection.addRange(range);
|
|
158
|
+
button.textContent = "Select & copy";
|
|
159
|
+
setTimeout(() => { button.textContent = "Copy"; selection.removeAllRanges(); }, 1800);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
update();
|
|
163
|
+
})();
|
|
164
|
+
</script>
|
|
165
|
+
</body></html>`;
|
|
166
|
+
|
|
167
|
+
function openBrowser() {
|
|
168
|
+
const url = `http://localhost:${port}`;
|
|
169
|
+
const platform = process.platform;
|
|
170
|
+
const command = platform === "win32" ? "cmd.exe" : platform === "darwin" ? "open" : "xdg-open";
|
|
171
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
172
|
+
const browser = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
173
|
+
browser.on("error", (error) => console.error(`Could not open a browser automatically: ${error.message}`));
|
|
174
|
+
browser.unref();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const server = http.createServer((request, response) => {
|
|
178
|
+
if (request.method !== "GET" || (request.url !== "/" && request.url !== "/index.html")) {
|
|
179
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff" });
|
|
180
|
+
response.end("Not found");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
response.writeHead(200, {
|
|
184
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
185
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';",
|
|
186
|
+
"X-Content-Type-Options": "nosniff",
|
|
187
|
+
"Cache-Control": "no-store",
|
|
188
|
+
});
|
|
189
|
+
response.end(page);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
server.on("error", (error) => {
|
|
193
|
+
console.error(`Could not start Authica preview at http://localhost:${port}: ${error.message}`);
|
|
194
|
+
process.exitCode = 1;
|
|
195
|
+
});
|
|
196
|
+
server.listen(port, "127.0.0.1", () => {
|
|
197
|
+
console.log(`Authica preview running at http://localhost:${port} (local-only; Ctrl+C to stop).`);
|
|
198
|
+
if (process.argv.includes("--open")) openBrowser();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
202
|
+
process.on(signal, () => server.close(() => process.exit(0)));
|
|
203
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "authica",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Generates theme-matched authentication UI for Next.js and Vite, with opt-in AST safety tools and a local preview studio.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"authica": "bin/cli.js"
|
|
@@ -13,10 +13,15 @@
|
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18"
|
|
15
15
|
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
16
19
|
"keywords": [
|
|
17
20
|
"authica",
|
|
18
21
|
"auth",
|
|
19
22
|
"authentication",
|
|
23
|
+
"webauthn",
|
|
24
|
+
"passkeys",
|
|
20
25
|
"nextjs-auth",
|
|
21
26
|
"supabase-auth",
|
|
22
27
|
"clerk-auth",
|
|
@@ -28,8 +33,12 @@
|
|
|
28
33
|
],
|
|
29
34
|
"repository": {
|
|
30
35
|
"type": "git",
|
|
31
|
-
"url": "https://github.com/AEGON247/authica.git"
|
|
36
|
+
"url": "git+https://github.com/AEGON247/authica.git"
|
|
32
37
|
},
|
|
33
38
|
"homepage": "https://github.com/AEGON247/authica#readme",
|
|
34
|
-
"license": "MIT"
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@babel/parser": "^7.29.13",
|
|
42
|
+
"recast": "^0.24.0"
|
|
43
|
+
}
|
|
35
44
|
}
|