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