initial commit

This commit is contained in:
root
2026-05-29 17:07:00 +02:00
commit c935e39fa1
30 changed files with 1263 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
Dockerfile
docker-compose.yml
.dockerignore
README.md
Symlink
+1
View File
@@ -0,0 +1 @@
.env.production
+14
View File
@@ -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/
+35
View File
@@ -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"]
+14
View File
@@ -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 },
);
}
+37
View File
@@ -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<string, unknown>;
return NextResponse.json(body);
}
+49
View File
@@ -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 });
}
+27
View File
@@ -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 });
}
+89
View File
@@ -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<string | null> {
// 1. Authorization: Bearer <jwt> (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 });
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useState, useTransition } from 'react';
export default function BillingPage() {
const [error, setError] = useState<string | null>(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 (
<div>
<h1>Billing</h1>
<div className="card">
<h2>Upgrade</h2>
<p className="muted">
v1 MVP uses a Stripe stub clicking Upgrade simulates a successful
checkout and unlocks unlimited bandwidth.
</p>
{error && <p className="error">{error}</p>}
<button onClick={onClick} disabled={isPending}>
{isPending ? 'Redirecting…' : 'Upgrade'}
</button>
</div>
</div>
);
}
+62
View File
@@ -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<string | null>(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 (
<div>
<h1>Upgrade complete</h1>
<div className="card">
{status === 'pending' && <p>Finalizing your upgrade</p>}
{status === 'ok' && (
<>
<p className="success">Your account has been upgraded.</p>
<Link className="btn" href="/dashboard">
Back to dashboard
</Link>
</>
)}
{status === 'error' && (
<>
<p className="error">{message}</p>
<Link className="btn secondary" href="/billing">
Try again
</Link>
</>
)}
</div>
</div>
);
}
+130
View File
@@ -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<CheckState>({ kind: 'idle' });
const [error, setError] = useState<string | null>(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 (
<form onSubmit={onSubmit}>
<label htmlFor="subdomain">Subdomain</label>
<div className="row">
<input
id="subdomain"
type="text"
value={subdomain}
onChange={(e) => setSubdomain(e.target.value.toLowerCase())}
required
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="smith"
/>
<span className="muted">.linumiq.net</span>
</div>
<div style={{ minHeight: '1.5em', marginTop: '0.25rem' }}>
{check.kind === 'checking' && (
<span className="muted">Checking availability</span>
)}
{check.kind === 'ok' && <span className="success">Available</span>}
{check.kind === 'taken' && <span className="error">Taken</span>}
{check.kind === 'invalid' && (
<span className="error">{check.message}</span>
)}
</div>
{error && <p className="error">{error}</p>}
<div className="row" style={{ marginTop: '1rem' }}>
<button type="submit" disabled={submitDisabled}>
{isPending ? 'Claiming…' : 'Claim subdomain'}
</button>
</div>
</form>
);
}
+124
View File
@@ -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<Tunnel>();
return (
<div>
<h1>Dashboard</h1>
<p className="muted">Signed in as {user.email}</p>
{tunnel ? (
<div className="card">
<h2>Your tunnel</h2>
<div className="kv">
<div className="k">Subdomain</div>
<div>
<a
href={`https://${tunnel.subdomain}.linumiq.net`}
target="_blank"
rel="noreferrer"
>
{tunnel.subdomain}.linumiq.net
</a>
</div>
<div className="k">Token</div>
<TokenReveal token={tunnel.token} />
<div className="k">Status</div>
<div>{tunnel.is_active ? 'Active' : 'Inactive'}</div>
<div className="k">Usage</div>
<div>
<div>
{formatBytes(tunnel.bytes_used)} /{' '}
{formatBytes(tunnel.quota_bytes)}
</div>
<div className="progress" style={{ marginTop: '0.25rem' }}>
<div
style={{
width: `${Math.min(
100,
(tunnel.bytes_used / Math.max(1, tunnel.quota_bytes)) *
100,
).toFixed(2)}%`,
}}
/>
</div>
</div>
<div className="k">Last seen</div>
<div>
{tunnel.last_seen_at
? new Date(tunnel.last_seen_at).toLocaleString()
: 'never'}
</div>
</div>
<p style={{ marginTop: '1.5rem' }}>
<a
href="https://github.com/linumiq/ha-apps/tree/main/frp-tunnel"
target="_blank"
rel="noreferrer"
>
Setup Home Assistant add-on
</a>
</p>
</div>
) : (
<div className="card">
<h2>Claim a subdomain</h2>
<p className="muted">
Pick a subdomain like <code>smith</code> to get{' '}
<code>smith.linumiq.net</code>. 332 chars, lowercase letters,
digits, hyphens. One per account.
</p>
<ClaimForm />
</div>
)}
</div>
);
}
+38
View File
@@ -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 (
<div>
<div className="token">{revealed ? token : masked}</div>
<div className="row" style={{ marginTop: '0.5rem' }}>
<button
type="button"
className="secondary"
onClick={() => setRevealed((r) => !r)}
>
{revealed ? 'Hide' : 'Reveal'}
</button>
<button type="button" className="secondary" onClick={copy}>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
);
}
+167
View File
@@ -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;
}
+55
View File
@@ -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 (
<html lang="en">
<body>
<nav className="nav">
<Link href="/" style={{ color: 'var(--fg)', fontWeight: 600 }}>
LinumIQ Tunnels
</Link>
<div className="row">
{user ? (
<>
<Link href="/dashboard">Dashboard</Link>
<Link href="/billing">Billing</Link>
<form action="/api/auth/signout" method="post" style={{ margin: 0 }}>
<button className="secondary" type="submit">
Sign out
</button>
</form>
</>
) : (
<>
<Link href="/login">Login</Link>
<Link href="/signup" className="btn">
Sign up
</Link>
</>
)}
</div>
</nav>
<main className="container">{children}</main>
</body>
</html>
);
}
+67
View File
@@ -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<string | null>(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 (
<div className="card">
<h1>Login</h1>
<form onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p className="error">{error}</p>}
<div className="row" style={{ marginTop: '1rem' }}>
<button type="submit" disabled={isPending}>
{isPending ? 'Signing in…' : 'Sign in'}
</button>
<Link className="muted" href="/signup">
Need an account?
</Link>
</div>
</form>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from 'next/link';
export default function HomePage() {
return (
<div>
<h1>Self-hosted tunnels for Home Assistant</h1>
<p className="muted">
Claim a subdomain, drop a token into the LinumIQ frp-tunnel add-on, and
expose your Home Assistant securely over HTTPS no router changes.
</p>
<div className="row" style={{ marginTop: '1.5rem' }}>
<Link className="btn" href="/signup">
Sign up
</Link>
<Link className="btn secondary" href="/login">
Login
</Link>
</div>
</div>
);
}
+75
View File
@@ -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<string | null>(null);
const [info, setInfo] = useState<string | null>(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 (
<div className="card">
<h1>Sign up</h1>
<form onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p className="error">{error}</p>}
{info && <p className="success">{info}</p>}
<div className="row" style={{ marginTop: '1rem' }}>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating…' : 'Create account'}
</button>
<Link className="muted" href="/login">
Already have one?
</Link>
</div>
</form>
</div>
);
}
+28
View File
@@ -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
+27
View File
@@ -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 },
});
}
+10
View File
@@ -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!,
);
}
+26
View File
@@ -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.
}
},
},
},
);
}
+30
View File
@@ -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 332 chars, lowercase az, 09, hyphen',
};
}
if (RESERVED_SUBDOMAINS.has(value)) {
return { ok: false, error: `'${value}' is reserved` };
}
return { ok: true, value };
}
+32
View File
@@ -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).*)'],
};
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
poweredByHeader: false,
};
export default nextConfig;
+23
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /api/
+22
View File
@@ -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"]
}