@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 +215 -0
- package/bin/authly.js +53 -0
- package/dist/dashboard/app.js +326 -0
- package/dist/dashboard/index.html +238 -0
- package/dist/dashboard/styles.css +742 -0
- package/package.json +48 -0
- package/src/auth/index.js +134 -0
- package/src/commands/audit.js +82 -0
- package/src/commands/init.js +67 -0
- package/src/commands/serve.js +383 -0
- package/src/generators/env.js +37 -0
- package/src/generators/migrations.js +138 -0
- package/src/generators/roles.js +67 -0
- package/src/generators/ui.js +619 -0
- package/src/lib/framework.js +29 -0
- package/src/lib/jwt.js +107 -0
- package/src/lib/oauth.js +301 -0
- package/src/lib/supabase.js +58 -0
- package/src/mcp/server.js +281 -0
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 & 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
|
+
});
|