commit c935e39fa1b140e02b7b3e8019ecd430235031b3 Author: root Date: Fri May 29 17:07:00 2026 +0200 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2fbd164 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.git +Dockerfile +docker-compose.yml +.dockerignore +README.md diff --git a/.env b/.env new file mode 120000 index 0000000..f4b7f93 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +.env.production \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ae88f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.idea/ +.vscode/ +node_modules/ +build/ +.DS_Store +*.tgz +my-app* +template/src/__tests__/__snapshots__/ +lerna-debug.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.changelog +.npm/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..88f00c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20.18.0-alpine AS deps +WORKDIR /app +RUN apk add --no-cache libc6-compat +COPY package.json ./ +RUN npm install --no-audit --no-fund --loglevel=error + +FROM node:20.18.0-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Build-time public env (baked into client bundle). +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY +ARG NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \ + NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY \ + NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +RUN npm run build + +FROM node:20.18.0-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/app/api/auth/signout/route.ts b/app/api/auth/signout/route.ts new file mode 100644 index 0000000..08167d8 --- /dev/null +++ b/app/api/auth/signout/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST() { + const supabase = createSupabaseServerClient(); + await supabase.auth.signOut(); + return NextResponse.redirect( + new URL('/', process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'), + { status: 303 }, + ); +} diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..8c02bb8 --- /dev/null +++ b/app/api/billing/checkout/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST() { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const stub = process.env.STRIPE_STUB_URL; + if (!stub) { + return NextResponse.json( + { error: 'STRIPE_STUB_URL not configured' }, + { status: 500 }, + ); + } + + const res = await fetch(`${stub}/v1/checkout/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customer_email: user.email }), + }); + if (!res.ok) { + return NextResponse.json( + { error: `stripe-stub returned ${res.status}` }, + { status: 502 }, + ); + } + const body = (await res.json()) as Record; + return NextResponse.json(body); +} diff --git a/app/api/billing/finalize/route.ts b/app/api/billing/finalize/route.ts new file mode 100644 index 0000000..77f0ed9 --- /dev/null +++ b/app/api/billing/finalize/route.ts @@ -0,0 +1,49 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + let session: string | null = null; + try { + const body = (await req.json()) as { session?: string }; + session = body.session ?? null; + } catch { + // ignore — session id is optional in stub + } + + const stub = process.env.STRIPE_STUB_URL; + if (!stub) { + return NextResponse.json( + { error: 'STRIPE_STUB_URL not configured' }, + { status: 500 }, + ); + } + + const res = await fetch(`${stub}/v1/webhooks/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session, + customer_email: user.email, + user_id: user.id, + }), + }); + if (!res.ok) { + return NextResponse.json( + { error: `stripe-stub returned ${res.status}` }, + { status: 502 }, + ); + } + const body = await res.json().catch(() => ({})); + return NextResponse.json({ ok: true, stub: body }); +} diff --git a/app/api/tunnel/check/route.ts b/app/api/tunnel/check/route.ts new file mode 100644 index 0000000..d7789ca --- /dev/null +++ b/app/api/tunnel/check/route.ts @@ -0,0 +1,27 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { validateSubdomain } from '@/lib/validation'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + const sub = req.nextUrl.searchParams.get('subdomain'); + const v = validateSubdomain(sub); + if (!v.ok) { + return NextResponse.json( + { available: false, error: v.error }, + { status: 200 }, + ); + } + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('tunnels') + .select('user_id') + .eq('subdomain', v.value) + .maybeSingle<{ user_id: string }>(); + if (error) { + return NextResponse.json({ available: false }, { status: 500 }); + } + return NextResponse.json({ available: !data }); +} diff --git a/app/api/tunnel/claim/route.ts b/app/api/tunnel/claim/route.ts new file mode 100644 index 0000000..a6e5f18 --- /dev/null +++ b/app/api/tunnel/claim/route.ts @@ -0,0 +1,89 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { randomBytes } from 'node:crypto'; +import { getSupabaseAdmin, getSupabaseAnon } from '@/lib/supabase/admin'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { validateSubdomain } from '@/lib/validation'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type ClaimBody = { subdomain?: unknown }; + +async function resolveUserId(req: NextRequest): Promise { + // 1. Authorization: Bearer (used by the E2E script) + const auth = req.headers.get('authorization') ?? ''; + const m = /^Bearer\s+(.+)$/i.exec(auth.trim()); + if (m) { + const anon = getSupabaseAnon(); + const { data, error } = await anon.auth.getUser(m[1]); + if (!error && data.user) return data.user.id; + } + // 2. Fallback to cookie session (browser). + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + return user?.id ?? null; +} + +export async function POST(req: NextRequest) { + const userId = await resolveUserId(req); + if (!userId) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + let body: ClaimBody; + try { + body = (await req.json()) as ClaimBody; + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + } + + const v = validateSubdomain(body.subdomain); + if (!v.ok) { + return NextResponse.json({ error: v.error }, { status: 400 }); + } + const subdomain = v.value; + + const admin = getSupabaseAdmin(); + + // Reject if the subdomain is already owned by another user. + const { data: existing, error: existingErr } = await admin + .from('tunnels') + .select('user_id') + .eq('subdomain', subdomain) + .maybeSingle<{ user_id: string }>(); + if (existingErr) { + return NextResponse.json({ error: existingErr.message }, { status: 500 }); + } + if (existing && existing.user_id !== userId) { + return NextResponse.json({ error: 'subdomain taken' }, { status: 409 }); + } + + const token = randomBytes(32).toString('hex'); + + const { data, error } = await admin + .from('tunnels') + .upsert( + { + user_id: userId, + subdomain, + token, + is_active: true, + }, + { onConflict: 'user_id' }, + ) + .select('subdomain, token') + .single<{ subdomain: string; token: string }>(); + + if (error) { + // 23505 = unique_violation — race on the subdomain column. + const code = (error as { code?: string }).code; + if (code === '23505') { + return NextResponse.json({ error: 'subdomain taken' }, { status: 409 }); + } + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ subdomain: data.subdomain, token: data.token }); +} diff --git a/app/billing/page.tsx b/app/billing/page.tsx new file mode 100644 index 0000000..58feb7a --- /dev/null +++ b/app/billing/page.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +export default function BillingPage() { + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function onClick() { + setError(null); + startTransition(async () => { + const res = await fetch('/api/billing/checkout', { method: 'POST' }); + if (!res.ok) { + setError(`Checkout failed (${res.status})`); + return; + } + const body = (await res.json()) as { url?: string }; + if (!body.url) { + setError('No checkout URL returned'); + return; + } + window.location.href = body.url; + }); + } + + return ( +
+

Billing

+
+

Upgrade

+

+ v1 MVP uses a Stripe stub — clicking Upgrade simulates a successful + checkout and unlocks unlimited bandwidth. +

+ {error &&

{error}

} + +
+
+ ); +} diff --git a/app/billing/success/page.tsx b/app/billing/success/page.tsx new file mode 100644 index 0000000..c30e6cd --- /dev/null +++ b/app/billing/success/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +export default function BillingSuccessPage() { + const [status, setStatus] = useState<'pending' | 'ok' | 'error'>('pending'); + const [message, setMessage] = useState(null); + + useEffect(() => { + const session = + typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get('session') + : null; + (async () => { + try { + const res = await fetch('/api/billing/finalize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: string; + }; + setStatus('error'); + setMessage(body.error ?? `Finalize failed (${res.status})`); + return; + } + setStatus('ok'); + } catch (e) { + setStatus('error'); + setMessage((e as Error).message); + } + })(); + }, []); + + return ( +
+

Upgrade complete

+
+ {status === 'pending' &&

Finalizing your upgrade…

} + {status === 'ok' && ( + <> +

Your account has been upgraded.

+ + Back to dashboard + + + )} + {status === 'error' && ( + <> +

{message}

+ + Try again + + + )} +
+
+ ); +} diff --git a/app/dashboard/claim-form.tsx b/app/dashboard/claim-form.tsx new file mode 100644 index 0000000..466f82c --- /dev/null +++ b/app/dashboard/claim-form.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import { createSupabaseBrowserClient } from '@/lib/supabase/browser'; +import { validateSubdomain } from '@/lib/validation'; + +type CheckState = + | { kind: 'idle' } + | { kind: 'checking' } + | { kind: 'ok' } + | { kind: 'taken' } + | { kind: 'invalid'; message: string }; + +export function ClaimForm() { + const router = useRouter(); + const [subdomain, setSubdomain] = useState(''); + const [check, setCheck] = useState({ kind: 'idle' }); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + const v = validateSubdomain(subdomain); + if (!subdomain) { + setCheck({ kind: 'idle' }); + return; + } + if (!v.ok) { + setCheck({ kind: 'invalid', message: v.error }); + return; + } + setCheck({ kind: 'checking' }); + const ctrl = new AbortController(); + const t = setTimeout(async () => { + try { + const res = await fetch( + `/api/tunnel/check?subdomain=${encodeURIComponent(v.value)}`, + { signal: ctrl.signal }, + ); + const body = (await res.json()) as { available?: boolean }; + setCheck(body.available ? { kind: 'ok' } : { kind: 'taken' }); + } catch (e) { + if ((e as { name?: string }).name !== 'AbortError') { + setCheck({ kind: 'idle' }); + } + } + }, 300); + return () => { + ctrl.abort(); + clearTimeout(t); + }; + }, [subdomain]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const v = validateSubdomain(subdomain); + if (!v.ok) { + setError(v.error); + return; + } + startTransition(async () => { + const supabase = createSupabaseBrowserClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + setError('Session expired. Please sign in again.'); + return; + } + const res = await fetch('/api/tunnel/claim', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ subdomain: v.value }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setError(body.error ?? `Request failed (${res.status})`); + return; + } + router.refresh(); + }); + } + + const submitDisabled = + isPending || + check.kind === 'invalid' || + check.kind === 'taken' || + check.kind === 'checking' || + !subdomain; + + return ( +
+ +
+ setSubdomain(e.target.value.toLowerCase())} + required + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + placeholder="smith" + /> + .linumiq.net +
+
+ {check.kind === 'checking' && ( + Checking availability… + )} + {check.kind === 'ok' && Available} + {check.kind === 'taken' && Taken} + {check.kind === 'invalid' && ( + {check.message} + )} +
+ {error &&

{error}

} +
+ +
+
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..6db6ff7 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,124 @@ +import { redirect } from 'next/navigation'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { ClaimForm } from './claim-form'; +import { TokenReveal } from './token-reveal'; + +export const dynamic = 'force-dynamic'; + +type Tunnel = { + subdomain: string; + token: string; + is_active: boolean; + bytes_used: number; + quota_bytes: number; + last_seen_at: string | null; + created_at: string; +}; + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ['KiB', 'MiB', 'GiB', 'TiB']; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(2)} ${units[i]}`; +} + +export default async function DashboardPage() { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + // Use service role so RLS doesn't surprise us here either. + const admin = getSupabaseAdmin(); + const { data: tunnel } = await admin + .from('tunnels') + .select( + 'subdomain, token, is_active, bytes_used, quota_bytes, last_seen_at, created_at', + ) + .eq('user_id', user.id) + .maybeSingle(); + + return ( +
+

Dashboard

+

Signed in as {user.email}

+ + {tunnel ? ( +
+

Your tunnel

+
+
Subdomain
+ + +
Token
+ + +
Status
+
{tunnel.is_active ? 'Active' : 'Inactive'}
+ +
Usage
+
+
+ {formatBytes(tunnel.bytes_used)} /{' '} + {formatBytes(tunnel.quota_bytes)} +
+
+
+
+
+ +
Last seen
+
+ {tunnel.last_seen_at + ? new Date(tunnel.last_seen_at).toLocaleString() + : 'never'} +
+
+ +

+ + Setup Home Assistant add-on → + +

+
+ ) : ( +
+

Claim a subdomain

+

+ Pick a subdomain like smith to get{' '} + smith.linumiq.net. 3–32 chars, lowercase letters, + digits, hyphens. One per account. +

+ +
+ )} +
+ ); +} diff --git a/app/dashboard/token-reveal.tsx b/app/dashboard/token-reveal.tsx new file mode 100644 index 0000000..678fb1a --- /dev/null +++ b/app/dashboard/token-reveal.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useState } from 'react'; + +export function TokenReveal({ token }: { token: string }) { + const [revealed, setRevealed] = useState(false); + const [copied, setCopied] = useState(false); + + const masked = `${token.slice(0, 6)}${'•'.repeat(20)}${token.slice(-4)}`; + + async function copy() { + try { + await navigator.clipboard.writeText(token); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + } + + return ( +
+
{revealed ? token : masked}
+
+ + +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..0c3f73b --- /dev/null +++ b/app/globals.css @@ -0,0 +1,167 @@ +:root { + --bg: #0f172a; + --fg: #f8fafc; + --muted: #94a3b8; + --card: #1e293b; + --border: #334155; + --accent: #3b82f6; + --accent-fg: #ffffff; + --danger: #ef4444; + --success: #22c55e; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + line-height: 1.5; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +.container { + max-width: 720px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--border); +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + margin: 1rem 0; +} + +h1, +h2, +h3 { + margin: 0 0 1rem; +} + +label { + display: block; + margin: 0.75rem 0 0.25rem; + font-size: 0.875rem; + color: var(--muted); +} + +input[type='text'], +input[type='email'], +input[type='password'] { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--fg); + font-size: 1rem; +} + +button, +.btn { + display: inline-block; + padding: 0.6rem 1rem; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-fg); + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + text-decoration: none; +} +button:hover, +.btn:hover { + opacity: 0.9; + text-decoration: none; +} +button.secondary, +.btn.secondary { + background: transparent; + color: var(--fg); + border-color: var(--border); +} + +.token { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + word-break: break-all; + font-size: 0.875rem; +} + +.error { + color: var(--danger); + margin: 0.5rem 0; + font-size: 0.875rem; +} +.success { + color: var(--success); + margin: 0.5rem 0; + font-size: 0.875rem; +} +.muted { + color: var(--muted); + font-size: 0.875rem; +} + +.progress { + width: 100%; + height: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} +.progress > div { + height: 100%; + background: var(--accent); +} + +.kv { + display: grid; + grid-template-columns: 160px 1fr; + gap: 0.5rem 1rem; + align-items: start; + margin: 0.5rem 0; +} +.kv .k { + color: var(--muted); + font-size: 0.875rem; +} + +.row { + display: flex; + gap: 0.5rem; + align-items: center; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..c91ce40 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import './globals.css'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; + +export const metadata: Metadata = { + title: 'LinumIQ Tunnels', + description: 'Remote access tunnels for Home Assistant', +}; + +export const dynamic = 'force-dynamic'; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + return ( + + + +
{children}
+ + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..2be5381 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { createSupabaseBrowserClient } from '@/lib/supabase/browser'; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const supabase = createSupabaseBrowserClient(); + startTransition(async () => { + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + if (error) { + setError(error.message); + return; + } + router.push('/dashboard'); + router.refresh(); + }); + } + + return ( +
+

Login

+
+ + setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> + {error &&

{error}

} +
+ + + Need an account? + +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..e8ae797 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link'; + +export default function HomePage() { + return ( +
+

Self-hosted tunnels for Home Assistant

+

+ Claim a subdomain, drop a token into the LinumIQ frp-tunnel add-on, and + expose your Home Assistant securely over HTTPS — no router changes. +

+
+ + Sign up + + + Login + +
+
+ ); +} diff --git a/app/signup/page.tsx b/app/signup/page.tsx new file mode 100644 index 0000000..4ce6204 --- /dev/null +++ b/app/signup/page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { createSupabaseBrowserClient } from '@/lib/supabase/browser'; + +export default function SignupPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + const [isPending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setInfo(null); + const supabase = createSupabaseBrowserClient(); + startTransition(async () => { + const { data, error } = await supabase.auth.signUp({ + email, + password, + }); + if (error) { + setError(error.message); + return; + } + if (data.session) { + router.push('/dashboard'); + router.refresh(); + } else { + setInfo('Check your email to confirm, then sign in.'); + } + }); + } + + return ( +
+

Sign up

+
+ + setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> + {error &&

{error}

} + {info &&

{info}

} +
+ + + Already have one? + +
+
+
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..054f1bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile + args: + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + image: web:1.0.0 + container_name: web + restart: unless-stopped + env_file: + - .env.production + expose: + - "3000" + networks: + - edge + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +networks: + edge: + external: true diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts new file mode 100644 index 0000000..a93dd72 --- /dev/null +++ b/lib/supabase/admin.ts @@ -0,0 +1,27 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; + +let _admin: SupabaseClient | null = null; + +export function getSupabaseAdmin(): SupabaseClient { + if (_admin) return _admin; + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) { + throw new Error('Supabase admin env not configured'); + } + _admin = createClient(url, key, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + return _admin; +} + +export function getSupabaseAnon(): SupabaseClient { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!url || !key) { + throw new Error('Supabase anon env not configured'); + } + return createClient(url, key, { + auth: { autoRefreshToken: false, persistSession: false }, + }); +} diff --git a/lib/supabase/browser.ts b/lib/supabase/browser.ts new file mode 100644 index 0000000..6250f89 --- /dev/null +++ b/lib/supabase/browser.ts @@ -0,0 +1,10 @@ +'use client'; + +import { createBrowserClient } from '@supabase/ssr'; + +export function createSupabaseBrowserClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); +} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts new file mode 100644 index 0000000..768e428 --- /dev/null +++ b/lib/supabase/server.ts @@ -0,0 +1,26 @@ +import { createServerClient } from '@supabase/ssr'; +import { cookies } from 'next/headers'; + +export function createSupabaseServerClient() { + const cookieStore = cookies(); + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(toSet) { + try { + toSet.forEach(({ name, value, options }) => { + cookieStore.set(name, value, options); + }); + } catch { + // Called from a Server Component — middleware will refresh. + } + }, + }, + }, + ); +} diff --git a/lib/validation.ts b/lib/validation.ts new file mode 100644 index 0000000..8d086dd --- /dev/null +++ b/lib/validation.ts @@ -0,0 +1,30 @@ +export const RESERVED_SUBDOMAINS = new Set([ + 'app', + 'api', + 'www', + 'admin', + 'auth', + 'mail', + 'static', +]); + +const SUBDOMAIN_RE = /^[a-z0-9-]{3,32}$/; + +export function validateSubdomain(input: unknown): + | { ok: true; value: string } + | { ok: false; error: string } { + if (typeof input !== 'string') { + return { ok: false, error: 'subdomain must be a string' }; + } + const value = input.trim().toLowerCase(); + if (!SUBDOMAIN_RE.test(value)) { + return { + ok: false, + error: 'subdomain must be 3–32 chars, lowercase a–z, 0–9, hyphen', + }; + } + if (RESERVED_SUBDOMAINS.has(value)) { + return { ok: false, error: `'${value}' is reserved` }; + } + return { ok: true, value }; +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..d7ef4e2 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,32 @@ +import { createServerClient } from '@supabase/ssr'; +import { NextResponse, type NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + let response = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(toSet) { + toSet.forEach(({ name, value }) => request.cookies.set(name, value)); + response = NextResponse.next({ request }); + toSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options), + ); + }, + }, + }, + ); + + await supabase.auth.getUser(); + return response; +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|api/tunnel/claim).*)'], +}; diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..6080add --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..26c87a8 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + reactStrictMode: true, + poweredByHeader: false, +}; +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1778394 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "linumiq-web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@supabase/ssr": "0.5.2", + "@supabase/supabase-js": "2.45.4", + "next": "14.2.15", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.16.10", + "@types/react": "18.3.11", + "@types/react-dom": "18.3.0", + "typescript": "5.6.2" + } +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..d346249 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /api/ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e6d4d50 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "baseUrl": ".", + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}