initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user