diff --git a/README.md b/README.md index 9f475a7..89fdc43 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,27 @@ Everything else is generated at build time. ```sh cd proof-of-work npm install +npx prisma generate # important after schema changes npx prisma db push # create the dev DB at prisma/data/app.db -npm run db:seed # admin@local / workout123 + curated exercise library +npm run db:seed # admin@local / workout123 + curated library + admin flag npm run dev # http://localhost:3000 ``` +## Multi-user + +Every install starts with one admin user (`admin@local`) and **sign-ups +closed**. To open the instance to additional users: + +- In-app: log in as admin -> **Settings -> Instance Settings -> + Allow new sign-ups**. +- StartOS: **Services -> Proof of Work -> Actions -> Set new signups**. + +Both write to the same `InstanceSettings` row; either path works. + +When sign-ups are open, anyone reaching the URL can create an account at +`/auth/signup`. New users start with no admin privileges and are +automatically seeded the full curated exercise library. + ## Building the StartOS package See **[start9/0.4/DEPLOY_040.md](start9/0.4/DEPLOY_040.md)** for the full diff --git a/proof-of-work/app/api/admin/signups/route.ts b/proof-of-work/app/api/admin/signups/route.ts new file mode 100644 index 0000000..28103e4 --- /dev/null +++ b/proof-of-work/app/api/admin/signups/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getCurrentUser } from '@/lib/auth'; +import { getInstanceSettings, setSignupsOpen } from '@/lib/instanceSettings'; + +/** + * GET -> { signupsOpen } any authenticated user (UI needs to know + * whether to show the admin toggle / sign-up CTA) + * POST -> { signupsOpen } admin only; flips the singleton flag + */ + +export async function GET() { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const settings = await getInstanceSettings(); + return NextResponse.json({ signupsOpen: settings.signupsOpen }); +} + +const bodySchema = z.object({ signupsOpen: z.boolean() }); + +export async function POST(request: NextRequest) { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!user.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid body', details: parsed.error.errors }, + { status: 400 }, + ); + } + const updated = await setSignupsOpen(parsed.data.signupsOpen); + return NextResponse.json({ signupsOpen: updated.signupsOpen }); +} diff --git a/proof-of-work/app/auth/login/page.tsx b/proof-of-work/app/auth/login/page.tsx index d19cecb..6912a82 100644 --- a/proof-of-work/app/auth/login/page.tsx +++ b/proof-of-work/app/auth/login/page.tsx @@ -95,12 +95,15 @@ export default function LoginPage() { {loading ? 'Signing in...' : 'Sign In'} + +

+ Don't have an account?{' '} + + Sign up + +

- -

- Demo: admin@example.com / password -

); diff --git a/proof-of-work/app/auth/signup/SignupForm.tsx b/proof-of-work/app/auth/signup/SignupForm.tsx new file mode 100644 index 0000000..048f58f --- /dev/null +++ b/proof-of-work/app/auth/signup/SignupForm.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { signupAction } from './actions'; + +export default function SignupForm() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const result = await signupAction(email, password, passwordConfirm, name); + if (result.error) { + setError(result.error); + setLoading(false); + return; + } + if (result.success) { + router.push('/main/dashboard'); + } + } catch (err) { + setError('An unexpected error occurred'); + setLoading(false); + } + }; + + return ( +
+
+ + setEmail(e.target.value)} + required + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" + disabled={loading} + /> +
+ +
+ + setName(e.target.value)} + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" + disabled={loading} + /> +
+ +
+ + setPasswordConfirm(e.target.value)} + required + minLength={8} + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all" + disabled={loading} + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +

+ Already have an account?{' '} + + Sign in + +

+
+ ); +} diff --git a/proof-of-work/app/auth/signup/actions.ts b/proof-of-work/app/auth/signup/actions.ts new file mode 100644 index 0000000..27d6ff5 --- /dev/null +++ b/proof-of-work/app/auth/signup/actions.ts @@ -0,0 +1,83 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { hashPassword, createSession } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { getInstanceSettings } from '@/lib/instanceSettings'; +import { ensureLibraryForUser } from '@/lib/library'; + +const EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/; + +export async function signupAction( + email: string, + password: string, + passwordConfirm: string, + name?: string, +) { + try { + const settings = await getInstanceSettings(); + if (!settings.signupsOpen) { + return { error: 'New sign-ups are not enabled on this instance.' }; + } + + if (!EMAIL_RE.test(email)) { + return { error: 'Enter a valid email address.' }; + } + if (password.length < 8) { + return { error: 'Password must be at least 8 characters.' }; + } + if (password !== passwordConfirm) { + return { error: 'Passwords do not match.' }; + } + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + // Don't leak existence — generic message keeps probing harder. + return { error: 'Could not create account with that email.' }; + } + + const passwordHash = await hashPassword(password); + + const user = await prisma.user.create({ + data: { + email, + passwordHash, + name: name?.trim() || null, + isAdmin: false, + userPreferences: { + create: { + theme: 'system', + defaultWeightUnit: 'lbs', + defaultRestSeconds: 90, + enableClaudeAI: false, + }, + }, + }, + }); + + // Seed the curated exercise library for the new user immediately so they + // see exercises on first load. The boot-time ensure step would do this + // on next restart anyway, but we don't want them to wait. + await ensureLibraryForUser(user.id); + + const session = await createSession(user.id); + const cookieStore = await cookies(); + cookieStore.set('sessionToken', session.token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + path: '/', + }); + + return { success: true }; + } catch (error) { + console.error('Signup error:', error); + return { error: 'An error occurred during sign-up.' }; + } +} + +export async function getSignupsOpen(): Promise { + const settings = await getInstanceSettings(); + return settings.signupsOpen; +} diff --git a/proof-of-work/app/auth/signup/page.tsx b/proof-of-work/app/auth/signup/page.tsx new file mode 100644 index 0000000..99c5bb7 --- /dev/null +++ b/proof-of-work/app/auth/signup/page.tsx @@ -0,0 +1,56 @@ +import { redirect } from 'next/navigation'; +import { getSignupsOpen } from './actions'; +import SignupForm from './SignupForm'; +import { getCurrentUser } from '@/lib/auth'; + +/** + * Server component wrapper. Reads the InstanceSettings.signupsOpen flag + * before rendering so closed instances don't even paint a form. Already- + * authenticated users get bounced to the dashboard rather than seeing + * sign-up. + */ +export default async function SignupPage() { + const user = await getCurrentUser(); + if (user) { + redirect('/main/dashboard'); + } + + const open = await getSignupsOpen(); + + return ( +
+
+
+
+

+ Proof of Work +

+

+ {open ? 'Create an account' : 'Sign-ups are closed'} +

+
+ +
+ {open ? ( + + ) : ( +
+

+ This Proof of Work instance isn't accepting new + sign-ups right now. Ask the admin to enable sign-ups + from Settings (or via the StartOS Action) and try again. +

+ + Back to sign in + +
+ )} +
+
+
+
+ ); +} diff --git a/proof-of-work/app/main/settings/page.tsx b/proof-of-work/app/main/settings/page.tsx index c3ec87e..b34f0db 100644 --- a/proof-of-work/app/main/settings/page.tsx +++ b/proof-of-work/app/main/settings/page.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; import { getCurrentUser } from "@/lib/auth"; import SettingsForm from "@/components/settings/SettingsForm"; +import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings"; +import { getInstanceSettings } from "@/lib/instanceSettings"; export default async function SettingsPage() { const user = await getCurrentUser(); @@ -9,6 +11,8 @@ export default async function SettingsPage() { redirect("/auth/login"); } + const instanceSettings = user.isAdmin ? await getInstanceSettings() : null; + return (
@@ -20,8 +24,13 @@ export default async function SettingsPage() {
-
+
+ {user.isAdmin && instanceSettings && ( + + )}
); diff --git a/proof-of-work/components/settings/AdminInstanceSettings.tsx b/proof-of-work/components/settings/AdminInstanceSettings.tsx new file mode 100644 index 0000000..9dba6d2 --- /dev/null +++ b/proof-of-work/components/settings/AdminInstanceSettings.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState } from 'react'; + +export default function AdminInstanceSettings({ + initialSignupsOpen, +}: { + initialSignupsOpen: boolean; +}) { + const [signupsOpen, setSignupsOpen] = useState(initialSignupsOpen); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const toggle = async (next: boolean) => { + setSaving(true); + setError(''); + const previous = signupsOpen; + setSignupsOpen(next); // optimistic + try { + const res = await fetch('/api/admin/signups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ signupsOpen: next }), + }); + if (!res.ok) { + setSignupsOpen(previous); + const body = await res.json().catch(() => ({})); + setError(body.error ?? `Save failed (${res.status})`); + } + } catch (e) { + setSignupsOpen(previous); + setError('Network error'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

+ Instance settings +

+

+ Admin only. Visible to admin accounts only. +

+
+ +
+
+

Allow new sign-ups

+

+ When enabled, anyone with the URL can create an account on this + instance. New users start with the curated exercise library and + no admin privileges. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/proof-of-work/lib/instanceSettings.ts b/proof-of-work/lib/instanceSettings.ts new file mode 100644 index 0000000..a90c6d3 --- /dev/null +++ b/proof-of-work/lib/instanceSettings.ts @@ -0,0 +1,23 @@ +import { prisma } from "./prisma"; + +/** + * Read the singleton InstanceSettings row, defensively creating it if + * missing. The compat ALTERs in docker_entrypoint.sh insert this row at + * boot time, but we still upsert here so that fresh dev installs (or + * any boot path that didn't run the entrypoint) see something usable. + */ +export async function getInstanceSettings() { + return prisma.instanceSettings.upsert({ + where: { id: 1 }, + update: {}, + create: { id: 1, signupsOpen: false }, + }); +} + +export async function setSignupsOpen(open: boolean) { + return prisma.instanceSettings.upsert({ + where: { id: 1 }, + update: { signupsOpen: open }, + create: { id: 1, signupsOpen: open }, + }); +} diff --git a/proof-of-work/lib/library.ts b/proof-of-work/lib/library.ts new file mode 100644 index 0000000..781b625 --- /dev/null +++ b/proof-of-work/lib/library.ts @@ -0,0 +1,69 @@ +import * as fs from "fs"; +import * as path from "path"; +import { prisma } from "./prisma"; + +/** + * In-process equivalent of prisma/ensureExerciseLibrary.cjs (which runs at + * container boot via docker_entrypoint.sh). Used when a new user signs up + * mid-runtime so they don't have to wait for the next boot to see the + * curated library. + * + * Reads the curated library from prisma/exercises.seed.json, then upserts + * each entry for the given user. Idempotent; never overwrites a user's + * own custom exercises (the unique constraint on (userId, name) prevents + * duplicates and `update: {}` on upsert keeps existing rows untouched). + */ + +interface LibraryExercise { + name: string; + description: string | null; + type: string; + muscleGroups: string[]; + inputFields: string[]; + defaultWeightUnit: string | null; +} + +let cached: LibraryExercise[] | null = null; + +function loadLibrary(): LibraryExercise[] { + if (cached) return cached; + const candidates = [ + // standalone runtime (Next.js server.js / Node) + path.resolve(process.cwd(), "prisma/exercises.seed.json"), + // .next/standalone runtime + path.resolve(process.cwd(), "../prisma/exercises.seed.json"), + // dev: cwd is repo subdir + path.resolve(__dirname, "../prisma/exercises.seed.json"), + ]; + for (const p of candidates) { + if (fs.existsSync(p)) { + cached = JSON.parse(fs.readFileSync(p, "utf8")) as LibraryExercise[]; + return cached; + } + } + console.warn("[library] exercises.seed.json not found in any candidate path"); + return []; +} + +export async function ensureLibraryForUser(userId: string): Promise { + const library = loadLibrary(); + let inserted = 0; + for (const ex of library) { + const result = await prisma.exercise.upsert({ + where: { userId_name: { userId, name: ex.name } }, + update: {}, + create: { + userId, + name: ex.name, + description: ex.description, + muscleGroups: JSON.stringify(ex.muscleGroups), + type: ex.type, + inputFields: JSON.stringify(ex.inputFields), + defaultWeightUnit: ex.defaultWeightUnit, + isCustom: false, + }, + }); + if (result) inserted++; + } + return inserted; +} diff --git a/proof-of-work/prisma/schema.prisma b/proof-of-work/prisma/schema.prisma index c5a3f53..6f4af32 100644 --- a/proof-of-work/prisma/schema.prisma +++ b/proof-of-work/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { email String @unique passwordHash String name String? + isAdmin Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -266,6 +267,16 @@ model AISuggestion { @@index([type]) } +/// Singleton row keyed on id=1. Holds instance-wide settings that aren't +/// tied to any single user (multi-user signup gate, feature flags, etc.). +/// Use `getInstanceSettings()` from lib/instanceSettings.ts to read; it +/// upserts the row defensively on first read so callers never see null. +model InstanceSettings { + id Int @id @default(1) + signupsOpen Boolean @default(false) + updatedAt DateTime @updatedAt +} + model UserPreferences { id String @id @default(cuid()) userId String @unique diff --git a/proof-of-work/prisma/seed.ts b/proof-of-work/prisma/seed.ts index 47b2fa2..1a7c735 100644 --- a/proof-of-work/prisma/seed.ts +++ b/proof-of-work/prisma/seed.ts @@ -48,15 +48,24 @@ async function main() { const user = await prisma.user.upsert({ where: { email: "admin@local" }, - update: {}, + update: { isAdmin: true }, create: { email: "admin@local", passwordHash: hashedPassword, name: "Admin User", + isAdmin: true, }, }); - console.log("Created/verified user:", user.id); + console.log("Created/verified admin user:", user.id); + + await prisma.instanceSettings.upsert({ + where: { id: 1 }, + update: {}, + create: { id: 1, signupsOpen: false }, + }); + + console.log("Created/verified InstanceSettings (signupsOpen: false)"); await prisma.userPreferences.upsert({ where: { userId: user.id }, diff --git a/start9/0.4/docker_entrypoint.sh b/start9/0.4/docker_entrypoint.sh index 5064658..de55e4f 100755 --- a/start9/0.4/docker_entrypoint.sh +++ b/start9/0.4/docker_entrypoint.sh @@ -75,6 +75,35 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then log "adding missing column Workout.deletedAt" sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;" fi + + # Multi-user support shipped in v1.0.0:1: User.isAdmin column + + # InstanceSettings singleton. New install: seed.ts creates both. Upgrade + # from a snapshot pulled off the legacy `workout-log` package: this block + # adds them in place, then promotes the oldest user to admin so the + # in-app admin Settings panel + change-credentials action keep working. + if ! sqlite3 "$DB_PATH" "PRAGMA table_info('User');" 2>/dev/null | grep -q "|isAdmin|"; then + log "adding missing column User.isAdmin (default 0)" + sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN isAdmin INTEGER NOT NULL DEFAULT 0;" + log "promoting oldest user to admin (one-shot, only if no admin exists)" + sqlite3 "$DB_PATH" \ + "UPDATE User SET isAdmin = 1 \ + WHERE id = (SELECT id FROM User ORDER BY createdAt ASC LIMIT 1) \ + AND NOT EXISTS (SELECT 1 FROM User WHERE isAdmin = 1);" + fi + + if ! sqlite3 "$DB_PATH" \ + "SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \ + 2>/dev/null | grep -q InstanceSettings; then + log "creating InstanceSettings table + singleton row (signupsOpen=0)" + sqlite3 "$DB_PATH" \ + "CREATE TABLE InstanceSettings ( \ + id INTEGER PRIMARY KEY DEFAULT 1, \ + signupsOpen INTEGER NOT NULL DEFAULT 0, \ + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP \ + );" + sqlite3 "$DB_PATH" \ + "INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);" + fi fi # ----------------------------------------------------------------------------- diff --git a/start9/0.4/startos/actions/changeAdminCredentials.ts b/start9/0.4/startos/actions/changeAdminCredentials.ts index 7f6d13b..8f9cd90 100644 --- a/start9/0.4/startos/actions/changeAdminCredentials.ts +++ b/start9/0.4/startos/actions/changeAdminCredentials.ts @@ -26,17 +26,19 @@ import { sdk } from '../sdk' * subcontainer. The plaintext password never lands in /proc, the SQL log, * or anywhere persistent. * - * - The UPDATE is keyed on `id = (SELECT id FROM User ORDER BY createdAt ASC - * LIMIT 1)` rather than `WHERE email = 'admin@local'` (the original 0.3.5 - * default). That makes the action safe to re-run after a previous rotation. - * The app is single-user by design, so this targets the only User row. + * - The UPDATE is keyed on the oldest user with `isAdmin = 1`, i.e. the + * primary admin identity. Safe to re-run after a previous rotation, and + * correct under the multi-user model: non-admin users created via + * /auth/signup are not targeted (admins reset other users' passwords + * from the in-app user management UI). * * - We assert exactly 1 row was updated (`changes() == 1`). Anything else * means the schema/data is in an unexpected state and we abort without * reporting success, so the user is forced to investigate before assuming - * credentials rotated successfully. - * - * Available from package version 0.1.0:20 onward. + * credentials rotated successfully. If you see "expected exactly 1 user + * row updated", check that at least one User has isAdmin=1 — the boot- + * time compat ALTERs auto-promote the oldest user, but a corrupt or + * externally-edited DB might not have a valid admin. */ const EMAIL_PATTERN = '^[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}$' @@ -137,7 +139,7 @@ export const changeAdminCredentials = sdk.Action.withInput( `SET email = ${sqlQuote(input.email)},`, ` passwordHash = ${sqlQuote(passwordHash)},`, ` updatedAt = (strftime('%s','now') * 1000)`, - `WHERE id = (SELECT id FROM User ORDER BY createdAt ASC LIMIT 1);`, + `WHERE id = (SELECT id FROM User WHERE isAdmin = 1 ORDER BY createdAt ASC LIMIT 1);`, 'SELECT changes();', 'COMMIT;', ].join('\n') diff --git a/start9/0.4/startos/actions/index.ts b/start9/0.4/startos/actions/index.ts index 5396f01..9c27fa4 100644 --- a/start9/0.4/startos/actions/index.ts +++ b/start9/0.4/startos/actions/index.ts @@ -1,11 +1,17 @@ import { sdk } from '../sdk' import { changeAdminCredentials } from './changeAdminCredentials' +import { toggleSignups } from './toggleSignups' /** * Package actions registered with StartOS. * - * - change-admin-credentials (added v0.1.0:20): rotate the admin email + - * password from the StartOS UI without dropping to a shell. See - * ./changeAdminCredentials.ts for full design notes. + * - change-admin-credentials: rotate the admin email + password from the + * StartOS UI without dropping to a shell. See changeAdminCredentials.ts + * for the full design notes. + * - toggle-signups: open/close the multi-user sign-up gate. The same + * toggle is also available in-app at Settings -> Instance Settings + * (admin only). See toggleSignups.ts. */ -export const actions = sdk.Actions.of().addAction(changeAdminCredentials) +export const actions = sdk.Actions.of() + .addAction(changeAdminCredentials) + .addAction(toggleSignups) diff --git a/start9/0.4/startos/actions/toggleSignups.ts b/start9/0.4/startos/actions/toggleSignups.ts new file mode 100644 index 0000000..ae486ed --- /dev/null +++ b/start9/0.4/startos/actions/toggleSignups.ts @@ -0,0 +1,118 @@ +import { sdk } from '../sdk' + +/** + * toggle-signups — StartOS Package Action. + * + * Sets `InstanceSettings.signupsOpen` (the multi-user signup gate). + * When `true`, anyone with the URL can create an account from the app's + * /auth/signup page. New users start with no admin privileges and the + * full curated exercise library. + * + * The same toggle is also available in-app at Settings -> Instance + * Settings (admin only). Both write to the same singleton row, so + * either path works. This StartOS action is the safety hatch for + * operators who don't have a working admin login (or aren't logged in + * yet on first install). + * + * Design notes: + * - allowedStatuses: 'only-running'. The app must be running for the + * write to be visible without restart, and the subcontainer needs + * /data mounted writable. We don't require a stop because the + * UPDATE is a single-row write that can't conflict with the + * long-running Next.js server. + * - Single explicit boolean input. Avoids the "I clicked it but did it + * turn on or off?" ambiguity of toggle-style actions. + * - The action does NOT report the current state in `getInput`. It's a + * setter, not a viewer; the in-app Settings page is the dashboard. + */ + +export const toggleSignups = sdk.Action.withInput( + 'toggle-signups', + async () => ({ + name: 'Set new signups', + description: + 'Allow or disallow anyone with the URL to create a Proof of Work account on this instance. The same toggle exists at in-app Settings -> Instance Settings (admin only).', + warning: + 'When sign-ups are open, anyone who can reach the URL can create an account. Make sure the instance is on a network you trust (LAN, Tor, VPN) before enabling.', + visibility: 'enabled', + allowedStatuses: 'only-running', + group: null, + }), + sdk.InputSpec.of({ + signupsOpen: sdk.Value.toggle({ + name: 'Allow new signups', + description: 'On = anyone with the URL can register. Off = closed.', + default: false, + }), + }), + async () => null, + async ({ effects, input }) => { + const flag = input.signupsOpen ? 1 : 0 + + await sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/data', + readonly: false, + }), + 'toggle-signups', + async (sc) => { + // Defensive: make sure the table exists. The boot-time compat ALTERs + // create it, but if this action runs before a first proper boot we + // want it to still succeed. + const sql = [ + `CREATE TABLE IF NOT EXISTS InstanceSettings (`, + ` id INTEGER PRIMARY KEY DEFAULT 1,`, + ` signupsOpen INTEGER NOT NULL DEFAULT 0,`, + ` updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP`, + `);`, + `INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, ${flag});`, + `UPDATE InstanceSettings SET signupsOpen = ${flag}, updatedAt = CURRENT_TIMESTAMP WHERE id = 1;`, + `SELECT signupsOpen FROM InstanceSettings WHERE id = 1;`, + ].join('\n') + + const res = await sc.execFail( + ['sqlite3', '/data/app.db'], + { input: sql }, + 30_000, + ) + const observed = res.stdout + .toString() + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .pop() + if (observed !== String(flag)) { + throw new Error( + `Aborting: wrote signupsOpen=${flag} but read back ${observed}. /data/app.db may be corrupt.`, + ) + } + }, + ) + + return { + version: '1', + title: input.signupsOpen ? 'Sign-ups enabled' : 'Sign-ups disabled', + message: input.signupsOpen + ? 'New visitors can now create accounts at /auth/signup.' + : 'New sign-ups are now closed. Existing users can still sign in.', + result: { + type: 'group', + value: [ + { + type: 'single', + name: 'signupsOpen', + description: 'Current value of InstanceSettings.signupsOpen', + value: String(input.signupsOpen), + copyable: false, + qr: false, + masked: false, + }, + ], + }, + } + }, +) diff --git a/start9/0.4/startos/versions/v1.0.0.1.ts b/start9/0.4/startos/versions/v1.0.0.1.ts index b6038d7..4cd2ab4 100644 --- a/start9/0.4/startos/versions/v1.0.0.1.ts +++ b/start9/0.4/startos/versions/v1.0.0.1.ts @@ -25,7 +25,7 @@ export const v_1_0_0_1 = VersionInfo.of({ version: '1.0.0:1', releaseNotes: { en_US: - 'Initial Proof of Work release. Replaces the legacy `workout-log` package with multi-user support and a curated exercise library shared across all users on the instance. Bakes a one-time seed of /data into the image and copies it into the new volume only on truly-fresh first boot, so an operator migrating from `workout-log` keeps every workout, exercise, and preference.', + 'Initial Proof of Work release. Replaces the legacy `workout-log` package with: (1) multi-user support — anyone with the URL can sign up when admin enables it, via Settings or the new "Set new signups" StartOS action; (2) a curated exercise library shared across all users — additive on every upgrade, so new exercises shipped by the maintainer reach existing installs without overwriting users\' own custom entries; (3) one-time seeded cutover from /data on the legacy `workout-log` host so every workout, exercise, and preference comes across; (4) the `change-admin-credentials` StartOS action targeting the primary admin (User.isAdmin = 1).', }, migrations: { up: async () => {},