@rblez/authly 0.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/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # Authly
2
+
3
+ **Authly** is a local auth dashboard for Next.js + Supabase. No global installs. No third-party dependencies. Your data, your server.
4
+
5
+ ```bash
6
+ npx @rblez/authly
7
+ ```
8
+
9
+ <p align="center">
10
+ <img src="https://img.shields.io/badge/status-in_construction-black" alt="Status">
11
+ <img src="https://img.shields.io/node/v/@rblez/authly" alt="Node version">
12
+ <img src="https://img.shields.io/github/license/rblez/authly" alt="License">
13
+ </p>
14
+
15
+ ---
16
+
17
+ ## Why it exists
18
+
19
+ - I don't want to depend on Supabase Auth, Clerk, or Better Auth
20
+ - I want auth ready in 10 minutes on any new project
21
+ - I want to manage users from a panel while developing
22
+ - Everything runs on my own infrastructure
23
+
24
+ ---
25
+
26
+ ## How it works
27
+
28
+ 1. Run `npx @rblez/authly` in your project
29
+ 2. It detects your stack (Next.js App Router)
30
+ 3. Starts a dashboard at `localhost:1284`
31
+ 4. Configure providers, roles, sessions from the UI
32
+ 5. Generates files, migrations, and env vars automatically
33
+ 6. While running — live user panel via internal API
34
+ 7. In production — it doesn't exist, zero exposed surface
35
+
36
+ ---
37
+
38
+ ## Quick start
39
+
40
+ ```bash
41
+ # 1. In any project directory
42
+ npx @rblez/authly
43
+
44
+ # 2. Set Supabase credentials
45
+ export SUPABASE_URL=""
46
+ export SUPABASE_ANON_KEY=""
47
+ export SUPABASE_SERVICE_ROLE_KEY=""
48
+ export AUTHLY_SECRET="$(openssl rand -hex 32)"
49
+
50
+ # 3. Configure OAuth providers
51
+ export GOOGLE_CLIENT_ID=""
52
+ export GOOGLE_CLIENT_SECRET=""
53
+ export GITHUB_CLIENT_ID=""
54
+ export GITHUB_CLIENT_SECRET=""
55
+
56
+ # 4. Dashboard runs at http://localhost:1284
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ # One-off (recommended)
65
+ npx @rblez/authly
66
+
67
+ # Or install locally
68
+ npm install @rblez/authly -D
69
+ npx authly
70
+ ```
71
+
72
+ **Requirements:**
73
+ - Node.js 20+
74
+ - A Supabase project
75
+ - A Next.js project with App Router
76
+
77
+ ---
78
+
79
+ ## Setup
80
+
81
+ Authly connects via environment variables or `authly.config.json`:
82
+
83
+ ```json
84
+ {
85
+ "framework": "nextjs",
86
+ "supabase": {
87
+ "url": "https://xxx.supabase.co",
88
+ "anonKey": "",
89
+ "serviceRoleKey": ""
90
+ }
91
+ }
92
+ ```
93
+
94
+ Or via `.env.local`:
95
+
96
+ ```bash
97
+ # required
98
+ SUPABASE_URL=""
99
+ SUPABASE_ANON_KEY=""
100
+ SUPABASE_SERVICE_ROLE_KEY=""
101
+ AUTHLY_SECRET="" # random string for JWT signing
102
+
103
+ # optional - providers
104
+ GOOGLE_CLIENT_ID=""
105
+ GOOGLE_CLIENT_SECRET=""
106
+ GITHUB_CLIENT_ID=""
107
+ GITHUB_CLIENT_SECRET=""
108
+
109
+ # optional - overrides
110
+ AUTHLY_PORT=1284
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Dashboard sections
116
+
117
+ | Section | What it does |
118
+ |---|---|
119
+ | **Init** | Connects to your project, detects stack |
120
+ | **Providers** | Enable Google, GitHub, Discord — buttons appear auto |
121
+ | **UI** | Generates login/signup TSX with SimpleIcon provider buttons |
122
+ | **Routes** | Auth middleware, redirects, UTM tracking |
123
+ | **Roles** | Role system: create roles, assign/revoke to users |
124
+ | **API Keys** | Generate and manage programmatic API keys |
125
+ | **Env** | Generates documented `.env.local` |
126
+ | **Migrate** | Runs SQL migrations without Supabase dashboard |
127
+ | **Users** | Live user panel via internal API |
128
+ | **Audit** | Checks config, env vars, and database connectivity |
129
+
130
+ ---
131
+
132
+ ## API Reference
133
+
134
+ | Method | Endpoint | Description |
135
+ |---|---|---|
136
+ | `GET` | `/api/health` | Health check |
137
+ | `GET` | `/api/auth/providers` | List OAuth providers + status |
138
+ | `POST` | `/api/auth/register` | Sign up email + password |
139
+ | `POST` | `/api/auth/login` | Sign in email + password |
140
+ | `GET` | `/api/auth/me` | Verify session (Bearer token) |
141
+ | `GET` | `/api/users` | List users from Supabase |
142
+ | `GET` | `/api/roles` | List roles |
143
+ | `POST` | `/api/roles` | Create role |
144
+ | `POST` | `/api/keys` | Generate API key |
145
+ | `GET` | `/api/migrations` | List available migrations |
146
+ | `POST` | `/api/audit` | Run config audit |
147
+
148
+ ---
149
+
150
+ ## Stack
151
+
152
+ | Layer | Tech |
153
+ |---|---|
154
+ | **Runtime** | Node.js 18+ |
155
+ | **Server** | Hono |
156
+ | **Database** | Supabase (PostgreSQL) |
157
+ | **Sessions** | JWT via `jose` (HS256) |
158
+ | **Password** | bcryptjs |
159
+ | **OAuth** | Direct provider APIs + authly SDK |
160
+ | **Output** | Next.js (TSX) + Supabase migrations |
161
+
162
+ ---
163
+
164
+ ## Design Rules
165
+
166
+ - Black by default, always
167
+ - SF Pro Text + SimpleIcons for provider logos
168
+ - Zero data on third parties — everything in your Supabase
169
+ - Never runs in production — development only
170
+ - Plug-and-play — functional in under 10 minutes
171
+
172
+ ---
173
+
174
+ ## Roadmap
175
+
176
+ ### v0.1 — Core (personal use)
177
+ - [x] `npx @rblez/authly` starts local server
178
+ - [x] Base dashboard UI (black, SF Pro, Remixicon)
179
+ - [x] Init — detects Next.js + connects Supabase
180
+ - [x] Generates `users` table migration
181
+ - [x] Generates `.env.local`
182
+
183
+ ### v0.2 — Auth in production
184
+ - [ ] Provider: Magic Link (Resend)
185
+ - [x] Provider: Google OAuth
186
+ - [x] Provider: GitHub OAuth
187
+ - [x] Scaffold login/signup TSX
188
+ - [x] Protected route middleware
189
+ - [x] Password auth (login/register)
190
+
191
+ ### v0.3 — User panel
192
+ - [x] Internal API that fetches users from Supabase
193
+ - [x] Live user view in dashboard
194
+ - [x] Basic roles (admin / user)
195
+
196
+ ### v0.4 — Complete DX
197
+ - [x] API Keys — generation and validation
198
+ - [x] UTM tracking on redirects
199
+ - [x] `authly audit` — detects config issues
200
+ - [ ] Inline documentation in dashboard
201
+
202
+ ### v1.0 — Public Release
203
+ - [ ] Tested on Tiendly, smsnumerovirtual, and a third project
204
+ - [x] Complete README
205
+ - [ ] Published on npm as `@rblez/authly`
206
+ - [x] Public repo on github.com/rblez/authly
207
+
208
+ ### Future
209
+ - [ ] Multi-DB support (PostgreSQL direct, MongoDB, etc.)
210
+
211
+ ---
212
+
213
+ ## License
214
+
215
+ MIT — rblez
package/bin/authly.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { cmdServe } from "../src/commands/serve.js";
4
+ import { cmdInit } from "../src/commands/init.js";
5
+ import { cmdAudit } from "../src/commands/audit.js";
6
+ import chalk from "chalk";
7
+
8
+ const COMMANDS = {
9
+ serve: { description: "Start the local auth dashboard", handler: cmdServe },
10
+ init: { description: "Initialize authly in your project", handler: cmdInit },
11
+ audit: { description: "Check auth configuration for issues", handler: cmdAudit },
12
+ version: { description: "Show version", handler: () => console.log("0.1.0") },
13
+ };
14
+
15
+ async function main() {
16
+ const args = process.argv.slice(2);
17
+ const command = args[0];
18
+
19
+ if (!command || command === "--help" || command === "-h") {
20
+ printHelp();
21
+ return;
22
+ }
23
+
24
+ if (command === "--version" || command === "-v") {
25
+ COMMANDS.version.handler();
26
+ return;
27
+ }
28
+
29
+ const cmd = COMMANDS[command];
30
+ if (!cmd) {
31
+ console.error(`Unknown command: ${chalk.red(command)}\n`);
32
+ printHelp();
33
+ process.exit(1);
34
+ }
35
+
36
+ await cmd.handler(args.slice(1));
37
+ }
38
+
39
+ function printHelp() {
40
+ console.log(chalk.bold("\n Authly — local auth dashboard for Next.js + Supabase\n"));
41
+ console.log(chalk.bold(" Usage:"));
42
+ console.log(` npx @rblez/authly <command>\n`);
43
+ console.log(chalk.bold(" Commands:"));
44
+ for (const [name, info] of Object.entries(COMMANDS)) {
45
+ console.log(` ${chalk.cyan(name.padEnd(10))} ${info.description}`);
46
+ }
47
+ console.log();
48
+ }
49
+
50
+ main().catch((err) => {
51
+ console.error(err);
52
+ process.exit(1);
53
+ });
@@ -0,0 +1,326 @@
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ const navItems = document.querySelectorAll(".nav-item");
3
+ const sections = document.querySelectorAll(".section");
4
+ const headerTitle = document.getElementById("headerTitle");
5
+ const sidebar = document.getElementById("sidebar");
6
+ const menuBtn = document.getElementById("menuBtn");
7
+ const statusText = document.getElementById("statusText");
8
+ const statusDot = document.querySelector(".header__dot");
9
+
10
+ checkHealth();
11
+
12
+ // ── Helpers ─────────────────────────────────────────
13
+ async function api(endpoint, opts = {}) {
14
+ const res = await fetch(`/api${endpoint}`, {
15
+ headers: { "Content-Type": "application/json" },
16
+ ...opts,
17
+ });
18
+ return res.json();
19
+ }
20
+
21
+ function showResult(el, text, type = "info") {
22
+ el.textContent = "";
23
+ el.classList.remove("hidden", "result--ok", "result--error", "result--info");
24
+ el.classList.add(`result--${type}`);
25
+ el.textContent = text;
26
+ }
27
+
28
+ function hideResult(el) {
29
+ el.classList.add("hidden");
30
+ el.classList.remove("result--ok", "result--error", "result--info");
31
+ }
32
+
33
+ function showToast(msg, type = "ok") {
34
+ const toast = document.getElementById("toast");
35
+ toast.textContent = msg;
36
+ toast.className = `toast toast--${type}`;
37
+ setTimeout(() => toast.classList.add("hidden"), 3500);
38
+ }
39
+
40
+ // ── Health ──────────────────────────────────────────
41
+ async function checkHealth() {
42
+ try {
43
+ const res = await fetch("/api/health");
44
+ if (res.ok) {
45
+ statusText.textContent = "Connected";
46
+ statusDot.style.background = "#22c55e";
47
+ } else {
48
+ setDisconnected();
49
+ }
50
+ } catch { setDisconnected(); }
51
+ }
52
+
53
+ function setDisconnected() {
54
+ statusText.textContent = "Disconnected";
55
+ statusDot.style.background = "#ef4444";
56
+ }
57
+
58
+ // ── Navigation ──────────────────────────────────────
59
+ for (const item of navItems) {
60
+ item.addEventListener("click", (e) => {
61
+ e.preventDefault();
62
+ switchSection(item.dataset.section);
63
+ });
64
+ }
65
+
66
+ function switchSection(id) {
67
+ for (const n of navItems) n.classList.remove("active");
68
+ document.querySelector(`[data-section="${id}"]`)?.classList.add("active");
69
+ for (const sec of sections) sec.classList.toggle("hidden", sec.id !== id);
70
+ headerTitle.textContent = document.querySelector(`[data-section="${id}"]`)?.textContent?.trim() ?? "";
71
+ sidebar.classList.remove("open");
72
+
73
+ // Lazy-load section data
74
+ if (id === "users") loadUsers();
75
+ if (id === "providers") loadProviders();
76
+ if (id === "roles") loadRoles();
77
+ if (id === "env") loadEnv();
78
+ if (id === "migrate") loadMigrations();
79
+ if (id === "init") checkInitFiles();
80
+ }
81
+
82
+ // ── Init ────────────────────────────────────────────
83
+ checkInitFiles();
84
+
85
+ const initForm = document.getElementById("initForm");
86
+ if (initForm) {
87
+ initForm.addEventListener("submit", async (e) => {
88
+ e.preventDefault();
89
+ const btn = document.getElementById("initBtn");
90
+ const result = document.getElementById("initResult");
91
+ btn.disabled = true;
92
+ btn.textContent = "Connecting…";
93
+ hideResult(result);
94
+
95
+ const data = await api("/init/connect", {
96
+ method: "POST",
97
+ body: JSON.stringify({
98
+ supabaseUrl: document.getElementById("initUrl").value.trim(),
99
+ supabaseAnonKey: document.getElementById("initAnon").value.trim(),
100
+ supabaseServiceKey: document.getElementById("initService").value.trim(),
101
+ }),
102
+ });
103
+
104
+ btn.disabled = false;
105
+ btn.innerHTML = '<i class="ri-plug-line"></i> Connect &amp; Configure';
106
+
107
+ if (data.success) {
108
+ showResult(result, `Connected to Supabase via ${data.framework.name} (${data.framework.version})`, "ok");
109
+ showToast("Project connected successfully");
110
+ checkInitFiles();
111
+ checkHealth();
112
+ } else {
113
+ showResult(result, data.error, "error");
114
+ }
115
+ });
116
+ }
117
+
118
+ async function checkInitFiles() {
119
+ try {
120
+ const res = await fetch("/api/config");
121
+ if (res.ok) {
122
+ const envCheck = await fetch("/api/health");
123
+ document.getElementById("envStatus").textContent = "checked";
124
+ document.getElementById("envStatus").className = "file-status file-status--ok";
125
+ document.getElementById("configStatus").textContent = "checked";
126
+ document.getElementById("configStatus").className = "file-status file-status--ok";
127
+ }
128
+ } catch {}
129
+ }
130
+
131
+ // ── Users ───────────────────────────────────────────
132
+ window.loadUsers = async function() {
133
+ const body = document.getElementById("usersBody");
134
+ body.innerHTML = '<tr><td colspan="4" class="text-muted">Loading…</td></tr>';
135
+ try {
136
+ const data = await api("/users");
137
+ if (!data.success || !data.users?.length) {
138
+ body.innerHTML = '<tr><td colspan="4" class="text-muted">No users found. Connect Supabase in Init.</td></tr>';
139
+ return;
140
+ }
141
+ body.innerHTML = data.users.map(u =>
142
+ `<tr>
143
+ <td style="font-family:var(--mono);font-size:.8rem">${(u.id ?? "").slice(0, 8)}…</td>
144
+ <td>${u.email ?? "—"}</td>
145
+ <td>${u.raw_user_meta_data?.role ?? "user"}</td>
146
+ <td class="text-muted">${u.created_at ? new Date(u.created_at).toLocaleDateString() : "—"}</td>
147
+ </tr>`
148
+ ).join("");
149
+ } catch {
150
+ body.innerHTML = '<tr><td colspan="4" class="text-muted">Failed to load users</td></tr>';
151
+ }
152
+ };
153
+
154
+ const refreshBtn = document.getElementById("refreshUsers");
155
+ if (refreshBtn) refreshBtn.addEventListener("click", () => loadUsers());
156
+
157
+ // ── Providers ───────────────────────────────────────
158
+ window.loadProviders = async function() {
159
+ const container = document.getElementById("providerList");
160
+ const data = await api("/providers");
161
+ if (!data.providers) return;
162
+
163
+ container.innerHTML = data.providers.map(p =>
164
+ `<div class="provider-card">
165
+ <i class="ri-${p.name === "google" ? "google" : p.name === "github" ? "github" : p.name === "discord" ? "discord" : "shield-keyhole"}-line"></i>
166
+ <div class="provider-info">
167
+ <div class="provider-name">${p.name}</div>
168
+ <div class="provider-scopes">scope: ${p.scopes}</div>
169
+ </div>
170
+ <span class="provider-status ${p.enabled ? "enabled" : "disabled"}">${p.enabled ? "Configured" : "Not configured"}</span>
171
+ </div>`
172
+ ).join("");
173
+ };
174
+
175
+ // ── Roles ───────────────────────────────────────────
176
+ window.loadRoles = async function() {
177
+ const container = document.getElementById("rolesList");
178
+ const data = await api("/roles");
179
+ if (!data.success || !data.roles?.length) {
180
+ container.innerHTML = '<span class="text-muted">No roles. Connect Supabase and run migration.</span>';
181
+ return;
182
+ }
183
+ container.innerHTML = data.roles.map(r =>
184
+ `<span class="role-chip">${r.name}</span>`
185
+ ).join("");
186
+ };
187
+
188
+ const addRoleBtn = document.getElementById("addRoleBtn");
189
+ if (addRoleBtn) {
190
+ addRoleBtn.addEventListener("click", async () => {
191
+ const name = prompt("Role name (e.g. editor):");
192
+ if (!name) return;
193
+ const data = await api("/roles", {
194
+ method: "POST",
195
+ body: JSON.stringify({ name, description: "Custom role" }),
196
+ });
197
+ if (data.success) { showToast(`Role '${name}' created`); loadRoles(); }
198
+ else showToast(data.error || "Failed to create role", "error");
199
+ });
200
+ }
201
+
202
+ const assignForm = document.getElementById("roleAssignForm");
203
+ if (assignForm) {
204
+ assignForm.addEventListener("submit", async (e) => {
205
+ e.preventDefault();
206
+ const userId = document.getElementById("assignUserId").value.trim();
207
+ const roleName = document.getElementById("assignRoleName").value.trim();
208
+ if (!userId || !roleName) return;
209
+ const result = document.getElementById("roleAssignResult");
210
+ const data = await api(`/roles/${roleName}/users/${userId}/assign`, { method: "POST" });
211
+ if (data.success) { showResult(result, `Role '${roleName}' assigned to ${userId.slice(0,8)}…`, "ok"); }
212
+ else { showResult(result, data.error || "Failed", "error"); }
213
+ });
214
+ }
215
+
216
+ // ── API Keys ────────────────────────────────────────
217
+ const keyForm = document.getElementById("keyForm");
218
+ if (keyForm) {
219
+ keyForm.addEventListener("submit", async (e) => {
220
+ e.preventDefault();
221
+ const name = document.getElementById("keyName").value.trim();
222
+ if (!name) return;
223
+ const result = document.getElementById("keyResult");
224
+ const data = await api("/keys", { method: "POST", body: JSON.stringify({ name }) });
225
+ if (data.success && data.key) {
226
+ showResult(result, `Key generated (shown once):\n${data.key}`, "ok");
227
+ } else {
228
+ showResult(result, data.error || "Failed to generate key", "error");
229
+ }
230
+ });
231
+ }
232
+
233
+ // ── Env ─────────────────────────────────────────────
234
+ window.loadEnv = async function() {
235
+ const container = document.getElementById("envVars");
236
+ const vars = ["SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", "AUTHLY_SECRET", "GOOGLE_CLIENT_ID", "GITHUB_CLIENT_ID"];
237
+ const data = await api("/audit", { method: "POST" });
238
+ const checks = new Map();
239
+ if (data.issues) data.issues.forEach(i => checks.set(i.check, i.status));
240
+
241
+ container.innerHTML = vars.map(v => {
242
+ const status = checks.has(v) ? (checks.get(v) === "ok" ? "set" : "unset") : "unknown";
243
+ return `<div class="var-row">
244
+ <span class="var-name">${v}</span>
245
+ <span class="var-status ${status === "set" ? "var-status--set" : ""}">${status === "set" ? "● set" : status === "unknown" ? "— unknown" : "○ not set"}</span>
246
+ </div>`;
247
+ }).join("");
248
+ };
249
+
250
+ // ── Migrations ──────────────────────────────────────
251
+ window.loadMigrations = async function() {
252
+ const container = document.getElementById("migrationList");
253
+ const data = await api("/migrations");
254
+ if (!data.success) return;
255
+
256
+ container.innerHTML = data.migrations.map(m =>
257
+ `<div class="migration-item">
258
+ <i class="ri-file-code-line"></i>
259
+ <span>${m.name}</span>
260
+ <span class="text-muted" style="margin-left:4px">${m.description}</span>
261
+ <button class="btn btn--sm btn--primary" onclick="runMigration('${m.name}')">Run</button>
262
+ </div>`
263
+ ).join("");
264
+ };
265
+
266
+ window.runMigration = async function(name) {
267
+ const result = confirm(`Run migration '${name}'? This executes SQL directly.`);
268
+ if (!result) return;
269
+ const data = await api(`/migrations/${name}/run`, { method: "POST" });
270
+ if (data.success) showToast(`Migration '${name}' applied`);
271
+ else showToast(data.error || "Migration failed", "error");
272
+ };
273
+
274
+ // ── Audit ───────────────────────────────────────────
275
+ const auditBtn = document.getElementById("runAudit");
276
+ if (auditBtn) {
277
+ auditBtn.addEventListener("click", async () => {
278
+ const resultDiv = document.getElementById("auditResult");
279
+ resultDiv.classList.remove("hidden");
280
+ resultDiv.textContent = "Running…";
281
+
282
+ const data = await api("/audit", { method: "POST" });
283
+ let html = `<p style="margin-bottom:8px">${data.success ? "✔ All checks passed" : `✘ ${data.issues.length} issue(s)`}</p>`;
284
+ html += data.issues.map(i =>
285
+ `<div style="font-size:.8rem;padding:4px 0;display:flex;gap:6px">
286
+ <span>${i.status === "ok" ? "<span style='color:#22c55e'>✔</span>" : "<span style='color:#f87171'>✘</span>"}</span>
287
+ <span>${i.check}${i.detail ? `: ${i.detail}` : ""}</span>
288
+ </div>`
289
+ ).join("");
290
+ resultDiv.innerHTML = html;
291
+ });
292
+ }
293
+
294
+ // ── Scaffold ────────────────────────────────────────
295
+ for (const card of document.querySelectorAll(".scaffold-card")) {
296
+ card.addEventListener("click", async () => {
297
+ const type = card.dataset.scaffold;
298
+ const preview = document.getElementById("scaffoldPreview");
299
+ const code = document.getElementById("scaffoldCode");
300
+
301
+ const data = await api("/scaffold/preview", {
302
+ method: "POST",
303
+ body: JSON.stringify({ type }),
304
+ });
305
+
306
+ if (data.success) {
307
+ code.textContent = data.code;
308
+ preview.classList.remove("hidden");
309
+ }
310
+ });
311
+ }
312
+
313
+ // ── Mobile menu ─────────────────────────────────────
314
+ if (menuBtn) menuBtn.addEventListener("click", () => sidebar.classList.toggle("open"));
315
+
316
+ // ── Toggles ─────────────────────────────────────────
317
+ for (const toggle of document.querySelectorAll(".toggle")) {
318
+ toggle.addEventListener("click", () => {
319
+ toggle.setAttribute("aria-checked", String(toggle.getAttribute("aria-checked") !== "true"));
320
+ });
321
+ }
322
+
323
+ // ── Hash navigation ────────────────────────────────
324
+ const hash = location.hash.replace("#", "");
325
+ if (hash && document.getElementById(hash)) switchSection(hash);
326
+ });