ReviewOS

also looking at this

stacks/postline

Single-user auth: login, first-run registration, page + API gates

#11
Merged glennmichael123 wants to merge feat/auth into main
9 files +254 -22
app/Actions/Postline/RegisterFirstUserAction.tsadded+63-0
Changes to app/Actions/Postline/RegisterFirstUserAction.ts
@@ -0,0 +1,63 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { register } from '@stacksjs/auth'
4import { db } from '@stacksjs/database'
5import { response } from '@stacksjs/router'
6
7const 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 */
14export 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})
database/migrations/0000000030-create-users-table.sqladded+12-0
Changes to database/migrations/0000000030-create-users-table.sql
@@ -0,0 +1,12 @@
1CREATE 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);
12CREATE INDEX IF NOT EXISTS "users_email_name_index" ON "users" ("email", "name");
package.jsonmodified+1-1
Changes to package.json
@@ -53,7 +53,7 @@
5353 "release:major": "./buddy release --bump major",
5454 "changelog": "./buddy changelog",
5555 "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",
5757 "typecheck": "bun run build:framework-types && bun x tsc --noEmit",
5858 "types:check": "bun tsc --noEmit",
5959 "types:fix": "./buddy types:fix",
resources/views/layouts/postline.stxmodified+10-0
Changes to resources/views/layouts/postline.stx
@@ -7,6 +7,16 @@
77 <meta name="description" content="@yield('description', 'Postline is a clean personal workspace for social publishing.')">
88 <title>@yield('title', 'Postline')</title>
99 <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>
1020 <script>
1121 ;(() => {
1222 document.documentElement.setAttribute('data-theme', 'light')
resources/views/login.stxadded+114-0
Changes to resources/views/login.stx
@@ -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
resources/views/settings.stxmodified+6-0
Changes to resources/views/settings.stx
@@ -11,6 +11,7 @@
1111 <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing</h1>
1212 <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Defaults for Postline. Bluesky credentials live on Accounts.</p>
1313 </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>
1415 </header>
1516
1617 <section class="grid gap-3.5 lg:grid-cols-2">
@@ -52,6 +53,11 @@
5253 const root = document.querySelector('[data-testid="settings-page"]')
5354 if (!root) return
5455
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
5561 const summary = root.querySelector('[data-settings-bluesky-summary]')
5662 const pill = root.querySelector('[data-settings-bluesky-pill]')
5763 const charLimit = root.querySelector('[data-settings-char-limit]')
routes/api.tsmodified+26-19
Changes to routes/api.ts
@@ -13,45 +13,52 @@ import { response, route } from '@stacksjs/router'
1313 */
1414
1515// 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.
18route.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).
21route.post('/register', 'Actions/Postline/RegisterFirstUserAction').skipCsrf().rateLimit(3, 'minute')
22
1623route.get('/', () => response.text('hello world'))
1724route.get('/coming-soon', 'Controllers/ComingSoonController@index')
1825
1926route.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()
2532})
2633
2734route.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()
3037 route.get('/auth', 'Actions/Postline/LinkedInAuthAction').skipCsrf()
3138 route.get('/callback', 'Actions/Postline/LinkedInCallbackAction').skipCsrf()
3239})
3340
3441route.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()
3744 route.get('/auth', 'Actions/Postline/InstagramAuthAction').skipCsrf()
3845 route.get('/callback', 'Actions/Postline/InstagramCallbackAction').skipCsrf()
3946})
4047
4148route.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()
4451 route.get('/auth', 'Actions/Postline/ThreadsAuthAction').skipCsrf()
4552 route.get('/callback', 'Actions/Postline/ThreadsCallbackAction').skipCsrf()
4653})
4754
4855route.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()
5764})
storage/framework/core/actions/src/dev/views.tsmodified+18-1
Changes to storage/framework/core/actions/src/dev/views.ts
@@ -143,6 +143,10 @@ async function startDefaultServer() {
143143 auth: {
144144 cookieName: authCookie,
145145 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'],
146150 },
147151 onRequest: async (req: Request) => {
148152 const url = new URL(req.url)
@@ -175,8 +179,21 @@ async function startDefaultServer() {
175179 if (url.pathname === '/docs' || url.pathname.startsWith('/docs/'))
176180 return proxyToBackend(req, docsBase, '/docs')
177181
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 }
179195 return proxyToBackend(req, apiBase)
196 }
180197
181198 // Optional `/locale/{code}` redirect (same as default SetLocaleAction).
182199 // STX i18n normally switches via `/<code>/…` paths from the injected
storage/framework/core/auth/build.tsmodified+4-1
Changes to storage/framework/core/auth/build.ts
@@ -15,7 +15,10 @@ const result = await Bun.build({
1515 external: frameworkExternal(),
1616 plugins: [
1717 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',
1922 outdir: './dist',
2023 }),
2124 ],