also looking at this
Single-user auth: login, first-run registration, page + API gates
#11
9 files
+254
-22
| @@ -0,0 +1,63 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { register } from '@stacksjs/auth' | |
| 4 | import { db } from '@stacksjs/database' | |
| 5 | import { response } from '@stacksjs/router' | |
| 6 | ||
| 7 | const database = db as any | |
| 8 | ||
| 9 | /** | |
| 10 | * Postline is a single-user workspace: the register endpoint only works | |
| 11 | * while no account exists, so a deployed instance can't be signed up on | |
| 12 | * by strangers. | |
| 13 | */ | |
| 14 | export default new Action({ | |
| 15 | name: 'Postline Register First User', | |
| 16 | description: 'Create the workspace account — only available on first run.', | |
| 17 | method: 'POST', | |
| 18 | ||
| 19 | async handle(request: RequestInstance) { | |
| 20 | const existing = await database | |
| 21 | .selectFrom('users') | |
| 22 | .select(['id']) | |
| 23 | .executeTakeFirst() | |
| 24 | ||
| 25 | if (existing) { | |
| 26 | return response.json({ | |
| 27 | ok: false, | |
| 28 | error: 'This workspace already has an account. Sign in instead.', | |
| 29 | }, { status: 403 }) | |
| 30 | } | |
| 31 | ||
| 32 | const email = String(request.get('email') || '').trim() | |
| 33 | const password = String(request.get('password') || '') | |
| 34 | const name = String(request.get('name') || '').trim() | |
| 35 | ||
| 36 | if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) | |
| 37 | return response.json({ ok: false, error: 'Email must be a valid email address.' }, { status: 422 }) | |
| 38 | if (password.length < 6 || password.length > 255) | |
| 39 | return response.json({ ok: false, error: 'Password must be between 6 and 255 characters.' }, { status: 422 }) | |
| 40 | if (name.length < 2 || name.length > 255) | |
| 41 | return response.json({ ok: false, error: 'Name must be between 2 and 255 characters.' }, { status: 422 }) | |
| 42 | ||
| 43 | try { | |
| 44 | const result = await register({ email, password, name }) | |
| 45 | if (!result) | |
| 46 | throw new Error('Registration failed.') | |
| 47 | ||
| 48 | return response.json({ | |
| 49 | ok: true, | |
| 50 | data: { | |
| 51 | access_token: (result as any).token, | |
| 52 | token_type: 'Bearer', | |
| 53 | }, | |
| 54 | }) | |
| 55 | } | |
| 56 | catch (error) { | |
| 57 | return response.json({ | |
| 58 | ok: false, | |
| 59 | error: error instanceof Error ? error.message : String(error), | |
| 60 | }, { status: 422 }) | |
| 61 | } | |
| 62 | }, | |
| 63 | }) | |
| @@ -0,0 +1,12 @@ | ||
| 1 | CREATE TABLE IF NOT EXISTS "users" ( | |
| 2 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 3 | "name" TEXT not null, | |
| 4 | "email" TEXT not null UNIQUE, | |
| 5 | "password" TEXT not null, | |
| 6 | "email_verified_at" TEXT, | |
| 7 | "public_passkey" TEXT, | |
| 8 | "created_at" TEXT not null default CURRENT_TIMESTAMP, | |
| 9 | "updated_at" TEXT, | |
| 10 | "uuid" TEXT | |
| 11 | ); | |
| 12 | CREATE INDEX IF NOT EXISTS "users_email_name_index" ON "users" ("email", "name"); | |
| @@ -53,7 +53,7 @@ | ||
| 53 | 53 | "release:major": "./buddy release --bump major", |
| 54 | 54 | "changelog": "./buddy changelog", |
| 55 | 55 | "generate": "./buddy generate", |
| 56 | "build:framework-types": "bun --filter='@stacksjs/types' --filter='@stacksjs/validation' --filter='@stacksjs/orm' --filter='@stacksjs/actions' --filter='@stacksjs/router' --filter='@stacksjs/email' --filter='@stacksjs/logging' --filter='@stacksjs/database' --filter='@stacksjs/env' --filter='@stacksjs/socials' run build", | |
| 56 | "build:framework-types": "bun --filter='@stacksjs/types' --filter='@stacksjs/validation' --filter='@stacksjs/orm' --filter='@stacksjs/actions' --filter='@stacksjs/router' --filter='@stacksjs/email' --filter='@stacksjs/logging' --filter='@stacksjs/database' --filter='@stacksjs/env' --filter='@stacksjs/socials' --filter='@stacksjs/auth' run build", | |
| 57 | 57 | "typecheck": "bun run build:framework-types && bun x tsc --noEmit", |
| 58 | 58 | "types:check": "bun tsc --noEmit", |
| 59 | 59 | "types:fix": "./buddy types:fix", |
| @@ -7,6 +7,16 @@ | ||
| 7 | 7 | <meta name="description" content="@yield('description', 'Postline is a clean personal workspace for social publishing.')"> |
| 8 | 8 | <title>@yield('title', 'Postline')</title> |
| 9 | 9 | <link rel="icon" href="/favicon.svg" type="image/svg+xml"> |
| 10 | <script> | |
| 11 | // Client-side auth guard: the server gate covers the listed pages, | |
| 12 | // and this covers `/` (which can't be a protected prefix without | |
| 13 | // also matching /login). The API rejects unauthenticated calls | |
| 14 | // regardless — this is UX, not the security boundary. | |
| 15 | ;(() => { | |
| 16 | if (!/(?:^|;\s*)auth-token=/.test(document.cookie)) | |
| 17 | window.location.replace('/login') | |
| 18 | })() | |
| 19 | </script> | |
| 10 | 20 | <script> |
| 11 | 21 | ;(() => { |
| 12 | 22 | document.documentElement.setAttribute('data-theme', 'light') |
| @@ -0,0 +1,114 @@ | ||
| 1 | @extends('layouts/default') | |
| 2 | ||
| 3 | @section('title', 'Postline - Sign in') | |
| 4 | @section('description', 'Sign in to your Postline workspace.') | |
| 5 | ||
| 6 | @section('content') | |
| 7 | <main class="grid place-items-center px-4 min-h-screen bg-[#f3f3f1]" data-testid="login-page"> | |
| 8 | <section class="grid gap-5 p-6 max-w-sm w-full bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 9 | <header class="grid gap-2 justify-items-center text-center"> | |
| 10 | <span class="grid place-items-center h-11 w-11 font-bold text-lg text-white bg-neutral-950 rounded-xl">P</span> | |
| 11 | <h1 class="m-0 font-bold text-[20px] text-neutral-950" data-login-heading>Sign in to Postline</h1> | |
| 12 | <p class="m-0 text-[13px] text-zinc-600" data-login-subtitle>Your personal publishing workspace.</p> | |
| 13 | </header> | |
| 14 | ||
| 15 | <form class="grid gap-3" data-login-form> | |
| 16 | <label class="hidden gap-1.5" data-login-name-row> | |
| 17 | <span class="font-bold text-xs text-zinc-500">Name</span> | |
| 18 | <input class="px-3 h-10 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="name" autocomplete="name" placeholder="Your name" data-login-name> | |
| 19 | </label> | |
| 20 | <label class="grid gap-1.5"> | |
| 21 | <span class="font-bold text-xs text-zinc-500">Email</span> | |
| 22 | <input class="px-3 h-10 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="email" type="email" autocomplete="username" placeholder="you@example.com" required data-login-email> | |
| 23 | </label> | |
| 24 | <label class="grid gap-1.5"> | |
| 25 | <span class="font-bold text-xs text-zinc-500">Password</span> | |
| 26 | <input class="px-3 h-10 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="password" type="password" autocomplete="current-password" placeholder="••••••••" required minlength="6" data-login-password> | |
| 27 | </label> | |
| 28 | <p class="hidden m-0 text-red-700 text-xs" data-login-error></p> | |
| 29 | <button class="inline-flex items-center justify-center px-4 h-10 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors" type="submit" data-login-submit>Sign in</button> | |
| 30 | </form> | |
| 31 | ||
| 32 | <button class="text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-login-toggle>First run? Create the workspace account</button> | |
| 33 | </section> | |
| 34 | </main> | |
| 35 | ||
| 36 | <script> | |
| 37 | ;(() => { | |
| 38 | const root = document.querySelector('[data-testid="login-page"]') | |
| 39 | if (!root) return | |
| 40 | ||
| 41 | const form = root.querySelector('[data-login-form]') | |
| 42 | const heading = root.querySelector('[data-login-heading]') | |
| 43 | const subtitle = root.querySelector('[data-login-subtitle]') | |
| 44 | const nameRow = root.querySelector('[data-login-name-row]') | |
| 45 | const nameInput = root.querySelector('[data-login-name]') | |
| 46 | const emailInput = root.querySelector('[data-login-email]') | |
| 47 | const passwordInput = root.querySelector('[data-login-password]') | |
| 48 | const errorText = root.querySelector('[data-login-error]') | |
| 49 | const submit = root.querySelector('[data-login-submit]') | |
| 50 | const toggle = root.querySelector('[data-login-toggle]') | |
| 51 | ||
| 52 | let registering = false | |
| 53 | ||
| 54 | const setError = (message) => { | |
| 55 | if (!errorText) return | |
| 56 | errorText.textContent = message || '' | |
| 57 | errorText.classList.toggle('hidden', !message) | |
| 58 | } | |
| 59 | ||
| 60 | const setMode = (register) => { | |
| 61 | registering = register | |
| 62 | nameRow?.classList.toggle('hidden', !register) | |
| 63 | nameRow?.classList.toggle('grid', register) | |
| 64 | if (heading) heading.textContent = register ? 'Create your workspace' : 'Sign in to Postline' | |
| 65 | if (subtitle) subtitle.textContent = register ? 'One account owns this Postline instance.' : 'Your personal publishing workspace.' | |
| 66 | if (submit) submit.textContent = register ? 'Create account' : 'Sign in' | |
| 67 | if (toggle) toggle.textContent = register ? 'Already set up? Sign in instead' : 'First run? Create the workspace account' | |
| 68 | setError('') | |
| 69 | } | |
| 70 | ||
| 71 | const storeTokenAndEnter = (token, expiresIn) => { | |
| 72 | const maxAge = Number(expiresIn) > 0 ? Number(expiresIn) : 60 * 60 * 24 * 30 | |
| 73 | document.cookie = `auth-token=${encodeURIComponent(token)}; path=/; max-age=${maxAge}; SameSite=Lax` | |
| 74 | window.location.assign('/') | |
| 75 | } | |
| 76 | ||
| 77 | toggle?.addEventListener('click', () => setMode(!registering)) | |
| 78 | ||
| 79 | form?.addEventListener('submit', async (event) => { | |
| 80 | event.preventDefault() | |
| 81 | setError('') | |
| 82 | submit?.setAttribute('disabled', 'disabled') | |
| 83 | ||
| 84 | const body = new FormData() | |
| 85 | body.set('email', emailInput?.value.trim() || '') | |
| 86 | body.set('password', passwordInput?.value || '') | |
| 87 | if (registering) body.set('name', nameInput?.value.trim() || '') | |
| 88 | ||
| 89 | try { | |
| 90 | const response = await fetch(registering ? '/api/register' : '/api/login', { method: 'POST', body }) | |
| 91 | const payload = await response.json().catch(() => ({})) | |
| 92 | ||
| 93 | if (registering) { | |
| 94 | if (!response.ok || payload.ok === false) throw new Error(payload.error || 'Could not create the account.') | |
| 95 | const token = payload.data?.access_token | |
| 96 | if (token) storeTokenAndEnter(token) | |
| 97 | else setMode(false) | |
| 98 | return | |
| 99 | } | |
| 100 | ||
| 101 | const token = payload.access_token || payload.token | |
| 102 | if (!response.ok || !token) throw new Error(payload.error || payload.message || 'Incorrect email or password.') | |
| 103 | storeTokenAndEnter(token, payload.expires_in) | |
| 104 | } | |
| 105 | catch (error) { | |
| 106 | setError(error instanceof Error ? error.message : String(error)) | |
| 107 | } | |
| 108 | finally { | |
| 109 | submit?.removeAttribute('disabled') | |
| 110 | } | |
| 111 | }) | |
| 112 | })() | |
| 113 | </script> | |
| 114 | @endsection | |
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing</h1> |
| 12 | 12 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Defaults for Postline. Bluesky credentials live on Accounts.</p> |
| 13 | 13 | </div> |
| 14 | <button class="inline-flex items-center justify-center px-4 h-9 font-bold text-neutral-950 text-sm bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" type="button" data-sign-out>Sign out</button> | |
| 14 | 15 | </header> |
| 15 | 16 | |
| 16 | 17 | <section class="grid gap-3.5 lg:grid-cols-2"> |
| @@ -52,6 +53,11 @@ | ||
| 52 | 53 | const root = document.querySelector('[data-testid="settings-page"]') |
| 53 | 54 | if (!root) return |
| 54 | 55 | |
| 56 | root.querySelector('[data-sign-out]')?.addEventListener('click', () => { | |
| 57 | document.cookie = 'auth-token=; path=/; max-age=0; SameSite=Lax' | |
| 58 | window.location.assign('/login') | |
| 59 | }) | |
| 60 | ||
| 55 | 61 | const summary = root.querySelector('[data-settings-bluesky-summary]') |
| 56 | 62 | const pill = root.querySelector('[data-settings-bluesky-pill]') |
| 57 | 63 | const charLimit = root.querySelector('[data-settings-char-limit]') |
| @@ -13,45 +13,52 @@ import { response, route } from '@stacksjs/router' | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | // Your custom routes go here: |
| 16 | // Auth: the framework's /login lives in the feature-gated dashboard route | |
| 17 | // bundle, which this app doesn't load — declare it directly. | |
| 18 | route.post('/login', 'Actions/Auth/LoginAction').skipCsrf().rateLimit(5, 'minute') | |
| 19 | // Single-user workspace: /register only works until the first account | |
| 20 | // exists (overrides the open framework default). | |
| 21 | route.post('/register', 'Actions/Postline/RegisterFirstUserAction').skipCsrf().rateLimit(3, 'minute') | |
| 22 | ||
| 16 | 23 | route.get('/', () => response.text('hello world')) |
| 17 | 24 | route.get('/coming-soon', 'Controllers/ComingSoonController@index') |
| 18 | 25 | |
| 19 | 26 | route.group({ prefix: '/postline/bluesky' }, () => { |
| 20 | route.get('/status', 'Actions/Postline/BlueskyStatusAction').skipCsrf() | |
| 21 | route.post('/connect', 'Actions/Postline/BlueskyConnectAction').skipCsrf() | |
| 22 | route.post('/publish', 'Actions/Postline/BlueskyPublishAction').skipCsrf() | |
| 23 | route.get('/link-preview', 'Actions/Postline/BlueskyLinkPreviewAction').skipCsrf() | |
| 24 | route.get('/timeline', 'Actions/Postline/BlueskyTimelineAction').skipCsrf() | |
| 27 | route.get('/status', 'Actions/Postline/BlueskyStatusAction').middleware('auth').skipCsrf() | |
| 28 | route.post('/connect', 'Actions/Postline/BlueskyConnectAction').middleware('auth').skipCsrf() | |
| 29 | route.post('/publish', 'Actions/Postline/BlueskyPublishAction').middleware('auth').skipCsrf() | |
| 30 | route.get('/link-preview', 'Actions/Postline/BlueskyLinkPreviewAction').middleware('auth').skipCsrf() | |
| 31 | route.get('/timeline', 'Actions/Postline/BlueskyTimelineAction').middleware('auth').skipCsrf() | |
| 25 | 32 | }) |
| 26 | 33 | |
| 27 | 34 | route.group({ prefix: '/postline/linkedin' }, () => { |
| 28 | route.get('/status', 'Actions/Postline/LinkedInStatusAction').skipCsrf() | |
| 29 | route.post('/connect', 'Actions/Postline/LinkedInConnectAction').skipCsrf() | |
| 35 | route.get('/status', 'Actions/Postline/LinkedInStatusAction').middleware('auth').skipCsrf() | |
| 36 | route.post('/connect', 'Actions/Postline/LinkedInConnectAction').middleware('auth').skipCsrf() | |
| 30 | 37 | route.get('/auth', 'Actions/Postline/LinkedInAuthAction').skipCsrf() |
| 31 | 38 | route.get('/callback', 'Actions/Postline/LinkedInCallbackAction').skipCsrf() |
| 32 | 39 | }) |
| 33 | 40 | |
| 34 | 41 | route.group({ prefix: '/postline/instagram' }, () => { |
| 35 | route.get('/status', 'Actions/Postline/InstagramStatusAction').skipCsrf() | |
| 36 | route.post('/connect', 'Actions/Postline/InstagramConnectAction').skipCsrf() | |
| 42 | route.get('/status', 'Actions/Postline/InstagramStatusAction').middleware('auth').skipCsrf() | |
| 43 | route.post('/connect', 'Actions/Postline/InstagramConnectAction').middleware('auth').skipCsrf() | |
| 37 | 44 | route.get('/auth', 'Actions/Postline/InstagramAuthAction').skipCsrf() |
| 38 | 45 | route.get('/callback', 'Actions/Postline/InstagramCallbackAction').skipCsrf() |
| 39 | 46 | }) |
| 40 | 47 | |
| 41 | 48 | route.group({ prefix: '/postline/threads' }, () => { |
| 42 | route.get('/status', 'Actions/Postline/ThreadsStatusAction').skipCsrf() | |
| 43 | route.post('/connect', 'Actions/Postline/ThreadsConnectAction').skipCsrf() | |
| 49 | route.get('/status', 'Actions/Postline/ThreadsStatusAction').middleware('auth').skipCsrf() | |
| 50 | route.post('/connect', 'Actions/Postline/ThreadsConnectAction').middleware('auth').skipCsrf() | |
| 44 | 51 | route.get('/auth', 'Actions/Postline/ThreadsAuthAction').skipCsrf() |
| 45 | 52 | route.get('/callback', 'Actions/Postline/ThreadsCallbackAction').skipCsrf() |
| 46 | 53 | }) |
| 47 | 54 | |
| 48 | 55 | route.group({ prefix: '/postline' }, () => { |
| 49 | route.get('/providers', 'Actions/Postline/ProvidersStatusAction').skipCsrf() | |
| 50 | route.post('/publish', 'Actions/Postline/CrosspostPublishAction').skipCsrf() | |
| 51 | route.get('/analytics', 'Actions/Postline/AnalyticsAction').skipCsrf() | |
| 52 | route.get('/blog', 'Actions/Postline/BlogListAction').skipCsrf() | |
| 53 | route.get('/queue', 'Actions/Postline/QueueListAction').skipCsrf() | |
| 54 | route.post('/queue', 'Actions/Postline/QueueSaveAction').skipCsrf() | |
| 55 | route.post('/queue/delete', 'Actions/Postline/QueueDeleteAction').skipCsrf() | |
| 56 | route.post('/queue/publish-now', 'Actions/Postline/QueuePublishNowAction').skipCsrf() | |
| 56 | route.get('/providers', 'Actions/Postline/ProvidersStatusAction').middleware('auth').skipCsrf() | |
| 57 | route.post('/publish', 'Actions/Postline/CrosspostPublishAction').middleware('auth').skipCsrf() | |
| 58 | route.get('/analytics', 'Actions/Postline/AnalyticsAction').middleware('auth').skipCsrf() | |
| 59 | route.get('/blog', 'Actions/Postline/BlogListAction').middleware('auth').skipCsrf() | |
| 60 | route.get('/queue', 'Actions/Postline/QueueListAction').middleware('auth').skipCsrf() | |
| 61 | route.post('/queue', 'Actions/Postline/QueueSaveAction').middleware('auth').skipCsrf() | |
| 62 | route.post('/queue/delete', 'Actions/Postline/QueueDeleteAction').middleware('auth').skipCsrf() | |
| 63 | route.post('/queue/publish-now', 'Actions/Postline/QueuePublishNowAction').middleware('auth').skipCsrf() | |
| 57 | 64 | }) |
| @@ -143,6 +143,10 @@ async function startDefaultServer() { | ||
| 143 | 143 | auth: { |
| 144 | 144 | cookieName: authCookie, |
| 145 | 145 | redirectTo: '/login', |
| 146 | // Postline pages require a signed-in user; `/` is guarded | |
| 147 | // client-side in the layout (a '/' prefix here would also match | |
| 148 | // /login and loop). The public blog stays open. | |
| 149 | protectedPaths: ['/queue', '/timeline', '/analytics', '/accounts', '/settings'], | |
| 146 | 150 | }, |
| 147 | 151 | onRequest: async (req: Request) => { |
| 148 | 152 | const url = new URL(req.url) |
| @@ -175,8 +179,21 @@ async function startDefaultServer() { | ||
| 175 | 179 | if (url.pathname === '/docs' || url.pathname.startsWith('/docs/')) |
| 176 | 180 | return proxyToBackend(req, docsBase, '/docs') |
| 177 | 181 | |
| 178 | if (isApiBoundRequest(req, url.pathname)) | |
| 182 | if (isApiBoundRequest(req, url.pathname)) { | |
| 183 | // Bridge cookie auth → bearer auth: the page gate stores the token | |
| 184 | // in the auth cookie, while bun-router's Auth middleware reads the | |
| 185 | // Authorization header. Inject it so browser fetches stay plain. | |
| 186 | if (!req.headers.get('authorization')) { | |
| 187 | const cookieHeader = req.headers.get('cookie') || '' | |
| 188 | const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${authCookie}=([^;]+)`)) | |
| 189 | if (match) { | |
| 190 | const headers = new Headers(req.headers) | |
| 191 | headers.set('authorization', `Bearer ${decodeURIComponent(match[1])}`) | |
| 192 | return proxyToBackend(new Request(req, { headers }), apiBase) | |
| 193 | } | |
| 194 | } | |
| 179 | 195 | return proxyToBackend(req, apiBase) |
| 196 | } | |
| 180 | 197 | |
| 181 | 198 | // Optional `/locale/{code}` redirect (same as default SetLocaleAction). |
| 182 | 199 | // STX i18n normally switches via `/<code>/…` paths from the injected |
| @@ -15,7 +15,10 @@ const result = await Bun.build({ | ||
| 15 | 15 | external: frameworkExternal(), |
| 16 | 16 | plugins: [ |
| 17 | 17 | dts({ |
| 18 | root: '.', | |
| 18 | // `root: '.'` nests declarations under dist/src/, leaving no | |
| 19 | // dist/index.d.ts beside dist/index.js — consumers then fail with | |
| 20 | // TS7016. Match the other framework packages (e.g. router). | |
| 21 | root: './src', | |
| 19 | 22 | outdir: './dist', |
| 20 | 23 | }), |
| 21 | 24 | ], |