initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
+14
@@ -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
@@ -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"]
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>. 3–32 chars, lowercase letters,
|
||||||
|
digits, hyphens. One per account.
|
||||||
|
</p>
|
||||||
|
<ClaimForm />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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!,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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).*)'],
|
||||||
|
};
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
reactStrictMode: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /api/
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user