Implement frontend session routing flow
- Validate and prepare access links in apps/frontend - Add session, ended, and unauthorized routes with polling - Copy full public access URLs from the admin links page
This commit is contained in:
23
.env.example
23
.env.example
@@ -8,15 +8,9 @@ DATABASE_URL=${{project.DATABASE_URL}}
|
||||
INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}}
|
||||
DEBUG_KEY=${{project.DEBUG_KEY}}
|
||||
|
||||
PUBLIC_URL=${{project.PUBLIC_URL}}
|
||||
|
||||
PROCESSOR_API_URL=${{project.PROCESSOR_API_URL}}
|
||||
APP_BUILDER_API_URL=${{project.APP_BUILDER_API_URL}}
|
||||
APP_BUILDER_ASSETS_PUBLIC_URL=${{project.APP_BUILDER_ASSETS_PUBLIC_URL}}
|
||||
|
||||
CLIENT_DOWNLOADED_APK_NAME=${{project.CLIENT_DOWNLOADED_APK_NAME}}
|
||||
|
||||
MOBILE_APP_API_URL=${{project.MOBILE_APP_API_URL}}
|
||||
ORCHESTRATOR_API_URL=${{project.ORCHESTRATOR_API_URL}}
|
||||
PUBLIC_WS_SCRCPY_SVC_URL=${{project.PUBLIC_WS_SCRCPY_SVC_URL}}
|
||||
PUBLIC_FRONTEND_URL=${{project.PUBLIC_FRONTEND_URL}}
|
||||
|
||||
BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}}
|
||||
BETTER_AUTH_URL=${{project.BETTER_AUTH_URL}}
|
||||
@@ -34,14 +28,3 @@ OTEL_EXPORTER_OTLP_HTTP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_HTTP_ENDPOINT}}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_ENDPOINT}}
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}}
|
||||
OTEL_RESOURCE_ATTRIBUTES=${{project.OTEL_RESOURCE_ATTRIBUTES}}
|
||||
|
||||
R2_BUCKET_NAME=${{project.R2_BUCKET_NAME}}
|
||||
R2_REGION=${{project.R2_REGION}}
|
||||
R2_ENDPOINT=${{project.R2_ENDPOINT}}
|
||||
R2_ACCESS_KEY=${{project.R2_ACCESS_KEY}}
|
||||
R2_SECRET_KEY=${{project.R2_SECRET_KEY}}
|
||||
R2_PUBLIC_URL=${{project.R2_PUBLIC_URL}}
|
||||
|
||||
MAX_FILE_SIZE=${{project.MAX_FILE_SIZE}}
|
||||
ALLOWED_MIME_TYPES=${{project.ALLOWED_MIME_TYPES}}
|
||||
ALLOWED_EXTENSIONS=${{project.ALLOWED_EXTENSIONS}}
|
||||
|
||||
@@ -23,7 +23,7 @@ More rules are only to be added by the human, in case such a suggestion becomes
|
||||
|
||||
- **Monorepo**: Turborepo + pnpm
|
||||
- **Language**: TypeScript everywhere, Node >= 24
|
||||
- **Apps**: `@apps/main` (SvelteKit), `@apps/front` (Hono), `@apps/orchestrator` (Hono)
|
||||
- **Apps**: `@apps/main` (SvelteKit), `@apps/frontend` (Hono), `@apps/orchestrator` (Hono)
|
||||
- **Packages**: `@pkg/logic`, `@pkg/db`, `@pkg/logger`, `@pkg/result`, `@pkg/keystore`, `@pkg/settings`
|
||||
- **DB**: PostgreSQL via Drizzle ORM; Redis (Valkey) via `@pkg/keystore`
|
||||
|
||||
@@ -34,11 +34,13 @@ More rules are only to be added by the human, in case such a suggestion becomes
|
||||
All domain logic lives in `@pkg/logic` under `packages/logic/domains/<domain>/` with four files: `data.ts`, `repository.ts`, `controller.ts`, `errors.ts`. Mirror this exactly when adding a domain.
|
||||
|
||||
**Path aliases** (logic package only):
|
||||
|
||||
- `@/*` → `./*` · `@domains/*` → `./domains/*` · `@core/*` → `./core/*`
|
||||
|
||||
**FlowExecCtx** (`fctx`) — passed into every domain operation for tracing:
|
||||
|
||||
```ts
|
||||
type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string; };
|
||||
type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
26
README.md
26
README.md
@@ -9,8 +9,8 @@ Currently in alpha. Greenfield. Subject to change.
|
||||
## How It Works
|
||||
|
||||
1. Admin generates a unique link and assigns it to a specific Android app on a specific device.
|
||||
2. User opens that link in their browser — served by `apps/front`.
|
||||
3. During the loading flow, `apps/front` validates the link and asks `apps/orchestrator` to reset the assigned Android session and launch the leased app.
|
||||
2. User opens that link in their browser — served by `apps/frontend`.
|
||||
3. During the loading flow, `apps/frontend` validates the link and asks `apps/orchestrator` to reset the assigned Android session and launch the leased app.
|
||||
4. If that device is already in use by another end user, the link fails instead of taking over the session.
|
||||
5. User is prompted to install the PWA.
|
||||
6. User opens the PWA — they are routed into a live stream of their assigned Android app session.
|
||||
@@ -53,24 +53,24 @@ Currently in alpha. Greenfield. Subject to change.
|
||||
- [x] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity)
|
||||
- [x] Link domain in `@pkg/logic` — controller + repository + errors
|
||||
- [x] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete
|
||||
- [ ] `apps/front`: validate incoming link token on request
|
||||
- [ ] `apps/front`: during loading, reject the link if the assigned device is already `inUse`
|
||||
- [ ] `apps/front`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session
|
||||
- [ ] `apps/front`: return appropriate error page for invalid/expired/revoked links
|
||||
- [ ] `apps/frontend`: validate incoming link token on request
|
||||
- [ ] `apps/frontend`: during loading, reject the link if the assigned device is already `inUse`
|
||||
- [ ] `apps/frontend`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session
|
||||
- [ ] `apps/frontend`: return appropriate error page for invalid/expired/revoked links
|
||||
- [ ] Front: keep on checking for link status change, if it gets revoked, we cutoff the connection
|
||||
|
||||
### PWA & User Session Flow (`apps/front`)
|
||||
### PWA & User Session Flow (`apps/frontend`)
|
||||
|
||||
- [ ] `apps/front`: serve static PWA shell (HTML + manifest + service worker)
|
||||
- [ ] `apps/front`: wait/loading page — just for show with a 3-5s duration
|
||||
- [ ] `apps/front`: PWA install prompt flow (beforeinstallprompt handling)
|
||||
- [ ] `apps/front`: session binding — tie the PWA launch to the user's allocated device
|
||||
- [ ] `apps/front`: route/proxy authenticated PWA requests to the Android instance stream
|
||||
- [ ] `apps/frontend`: serve static PWA shell (HTML + manifest + service worker)
|
||||
- [ ] `apps/frontend`: wait/loading page — just for show with a 3-5s duration
|
||||
- [ ] `apps/frontend`: PWA install prompt flow (beforeinstallprompt handling)
|
||||
- [ ] `apps/frontend`: session binding — tie the PWA launch to the user's allocated device
|
||||
- [ ] `apps/frontend`: route/proxy authenticated PWA requests to the Android instance stream
|
||||
|
||||
### Android Streaming (scrcpy + ws-scrcpy)
|
||||
|
||||
- [x] Docker-Android image setup and validation on VPS
|
||||
- [x] ws-scrcpy WebSocket server running per container, exposed via orchestrator
|
||||
- [ ] `apps/front`: scrcpy client embedded in PWA — renders the Android stream in browser
|
||||
- [ ] `apps/frontend`: scrcpy client embedded in PWA — renders the Android stream in browser
|
||||
- [ ] Input forwarding (touch/keyboard events → scrcpy → Android container)
|
||||
- [ ] Session timeout + stream teardown on inactivity
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5173",
|
||||
"dev": "vite dev --port 5174",
|
||||
"build": "NODE_ENV=build vite build",
|
||||
"prod": "HOST=0.0.0.0 PORT=3000 node ./build/index.js",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PUBLIC_WS_SCRCPY_SVC_URL } from "$env/static/public";
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
export const WS_SCRCPY_URL = PUBLIC_WS_SCRCPY_SVC_URL;
|
||||
export const WS_SCRCPY_URL = env.PUBLIC_WS_SCRCPY_SVC_URL ?? "";
|
||||
|
||||
export const TRANSITION_COLORS = "transition-colors duration-150 ease-in-out";
|
||||
export const TRANSITION_ALL = "transition-all duration-150 ease-in-out";
|
||||
|
||||
39
apps/frontend/src/lib/domains/link/client.ts
Normal file
39
apps/frontend/src/lib/domains/link/client.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { resolveLinkSQ } from "./link.remote";
|
||||
import type { Err } from "@pkg/result";
|
||||
|
||||
export type SessionEndReason =
|
||||
| "missing_token"
|
||||
| "invalid"
|
||||
| "expired"
|
||||
| "revoked"
|
||||
| "busy"
|
||||
| "not_found"
|
||||
| "network"
|
||||
| "unknown";
|
||||
|
||||
export async function resolveLinkFresh(token: string) {
|
||||
const query = resolveLinkSQ({ token });
|
||||
|
||||
if (query.ready) {
|
||||
await query.refresh();
|
||||
return query.current;
|
||||
}
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
export function mapLinkErrorToSessionEndReason(
|
||||
error?: Pick<Err, "code" | "message" | "description"> | null,
|
||||
): SessionEndReason {
|
||||
if (!error) return "unknown";
|
||||
|
||||
const text = `${error.code} ${error.message} ${error.description}`.toLowerCase();
|
||||
|
||||
if (text.includes("expired")) return "expired";
|
||||
if (text.includes("revoked")) return "revoked";
|
||||
if (text.includes("not found")) return "not_found";
|
||||
if (text.includes("busy") || text.includes("offline")) return "busy";
|
||||
if (text.includes("invalid") || text.includes("token")) return "invalid";
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { formatErrorDetail, logDomainEvent } from "@pkg/logger";
|
||||
import { DeviceStatus } from "@pkg/logic/domains/device/data";
|
||||
import { ERROR_CODES, type Err } from "@pkg/result";
|
||||
import { settings } from "@pkg/settings";
|
||||
import { WS_SCRCPY_URL } from "$lib/core/constants";
|
||||
|
||||
const lc = getLinkController();
|
||||
|
||||
@@ -35,6 +36,25 @@ type LinkSessionShape = {
|
||||
};
|
||||
};
|
||||
|
||||
const SCRCPY_SERVER_PORT = 8886;
|
||||
|
||||
function buildStreamUrl(host: string, wsPort: string): string | null {
|
||||
const trimmedHost = host.trim();
|
||||
const trimmedPort = wsPort.trim();
|
||||
|
||||
if (!trimmedHost || !trimmedPort) return null;
|
||||
|
||||
const udid = `${trimmedHost}:${trimmedPort}`;
|
||||
const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(
|
||||
/^http:/,
|
||||
"ws:",
|
||||
);
|
||||
const wsParam =
|
||||
`${baseWss}/?action=proxy-adb&remote=${encodeURIComponent(`tcp:${SCRCPY_SERVER_PORT}`)}&udid=${encodeURIComponent(udid)}`;
|
||||
|
||||
return `${WS_SCRCPY_URL}/#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`;
|
||||
}
|
||||
|
||||
async function getPreparedLinkContext(fctx: FlowExecCtx, token: string) {
|
||||
const linkResult = await lc.validate(fctx, token);
|
||||
if (linkResult.isErr()) {
|
||||
@@ -119,6 +139,7 @@ export async function resolveLinkFlow(fctx: FlowExecCtx, token: string) {
|
||||
isAvailable:
|
||||
link.device.status === DeviceStatus.ONLINE &&
|
||||
!link.device.inUse,
|
||||
streamUrl: buildStreamUrl(link.device.host, link.device.wsPort),
|
||||
},
|
||||
supportedApp: link.supportedApp,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
mapLinkErrorToSessionEndReason,
|
||||
resolveLinkFresh,
|
||||
} from "$lib/domains/link/client";
|
||||
import { prepareLinkSC } from "$lib/domains/link/link.remote";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
onMount(() => {});
|
||||
let state: "boot" | "validating" | "preparing" = "boot";
|
||||
let statusText = "Initializing session...";
|
||||
|
||||
onMount(() => {
|
||||
const run = async () => {
|
||||
const token =
|
||||
new URLSearchParams(window.location.search)
|
||||
.get("token")
|
||||
?.trim() || "";
|
||||
|
||||
if (!token) {
|
||||
await goto("/unauthorized?reason=missing_token", {
|
||||
replaceState: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state = "validating";
|
||||
statusText = "Validating your access link...";
|
||||
|
||||
const resolved = await resolveLinkFresh(token);
|
||||
if (resolved?.error || !resolved?.data) {
|
||||
const reason = mapLinkErrorToSessionEndReason(resolved?.error);
|
||||
await goto(
|
||||
`/session-ended?reason=${encodeURIComponent(reason)}`,
|
||||
{
|
||||
replaceState: true,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = "preparing";
|
||||
statusText = "Preparing your app session...";
|
||||
|
||||
const prepared = await prepareLinkSC({ token });
|
||||
if (prepared?.error || !prepared?.data) {
|
||||
const reason = mapLinkErrorToSessionEndReason(prepared?.error);
|
||||
await goto(
|
||||
`/session-ended?reason=${encodeURIComponent(reason)}`,
|
||||
{
|
||||
replaceState: true,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await goto(`/session?token=${encodeURIComponent(token)}`, {
|
||||
replaceState: true,
|
||||
});
|
||||
};
|
||||
|
||||
void run();
|
||||
});
|
||||
</script>
|
||||
|
||||
<span>base page to show the loading state</span>
|
||||
<main
|
||||
class="bg-background text-foreground flex min-h-screen items-center justify-center px-6"
|
||||
>
|
||||
<section
|
||||
class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center"
|
||||
>
|
||||
<h1 class="text-xl font-semibold">Starting your session</h1>
|
||||
<p class="mt-3 text-sm text-muted-foreground">{statusText}</p>
|
||||
<div class="mt-5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="bg-primary h-full animate-pulse"
|
||||
style={`width: ${state === "boot" ? "35%" : state === "validating" ? "65%" : "88%"}`}
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
26
apps/frontend/src/routes/session-ended/+page.svelte
Normal file
26
apps/frontend/src/routes/session-ended/+page.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const reasonMap: Record<string, string> = {
|
||||
revoked: "This session was revoked by an administrator.",
|
||||
expired: "This session expired.",
|
||||
busy: "The assigned device is not currently available.",
|
||||
not_found: "This session link could not be found.",
|
||||
invalid: "This session link is invalid.",
|
||||
network: "Connection was lost. Please try again.",
|
||||
unknown: "This session is no longer available.",
|
||||
};
|
||||
|
||||
const reason = browser
|
||||
? (new URLSearchParams(window.location.search).get("reason") ?? "unknown")
|
||||
: "unknown";
|
||||
</script>
|
||||
|
||||
<main class="bg-background text-foreground flex min-h-screen items-center justify-center px-6">
|
||||
<section class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center">
|
||||
<h1 class="text-xl font-semibold">Session ended</h1>
|
||||
<p class="mt-3 text-sm text-muted-foreground">
|
||||
{reasonMap[reason] || reasonMap.unknown}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
141
apps/frontend/src/routes/session/+page.svelte
Normal file
141
apps/frontend/src/routes/session/+page.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
mapLinkErrorToSessionEndReason,
|
||||
resolveLinkFresh,
|
||||
} from "$lib/domains/link/client";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
|
||||
|
||||
let state: "loading" | "ready" = "loading";
|
||||
let token = "";
|
||||
let streamUrl: string | null = null;
|
||||
let statusText = "Connecting to your active session...";
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let inFlight = false;
|
||||
let consecutivePollErrors = 0;
|
||||
|
||||
const endSession = async (reason: string) => {
|
||||
if (disposed) return;
|
||||
if (timer) clearInterval(timer);
|
||||
streamUrl = null;
|
||||
await goto(`/session-ended?reason=${encodeURIComponent(reason)}`, {
|
||||
replaceState: true,
|
||||
});
|
||||
};
|
||||
|
||||
const pollValidity = async () => {
|
||||
if (disposed || inFlight || !token) return;
|
||||
inFlight = true;
|
||||
|
||||
try {
|
||||
const result = await resolveLinkFresh(token);
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
if (result?.error || !result?.data) {
|
||||
await endSession(
|
||||
mapLinkErrorToSessionEndReason(result?.error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
consecutivePollErrors = 0;
|
||||
|
||||
if (result.data.link.status !== "active") {
|
||||
await endSession("expired");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStreamUrl = result.data.device.streamUrl;
|
||||
if (typeof nextStreamUrl === "string" && nextStreamUrl.length > 0) {
|
||||
streamUrl = nextStreamUrl;
|
||||
}
|
||||
} catch {
|
||||
consecutivePollErrors += 1;
|
||||
if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
|
||||
await endSession("network");
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
token =
|
||||
new URLSearchParams(window.location.search)
|
||||
.get("token")
|
||||
?.trim() || "";
|
||||
|
||||
if (!token) {
|
||||
await goto("/unauthorized?reason=missing_token", {
|
||||
replaceState: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const initial = await resolveLinkFresh(token);
|
||||
if (initial?.error || !initial?.data) {
|
||||
await endSession(mapLinkErrorToSessionEndReason(initial?.error));
|
||||
return;
|
||||
}
|
||||
|
||||
streamUrl = initial.data.device.streamUrl;
|
||||
state = "ready";
|
||||
statusText = "Session active";
|
||||
|
||||
await pollValidity();
|
||||
timer = setInterval(() => {
|
||||
void pollValidity();
|
||||
}, POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="bg-background text-foreground min-h-screen">
|
||||
{#if state === "loading"}
|
||||
<section class="flex min-h-screen items-center justify-center px-6">
|
||||
<div
|
||||
class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center"
|
||||
>
|
||||
<h1 class="text-xl font-semibold">Loading session</h1>
|
||||
<p class="mt-3 text-sm text-muted-foreground">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="flex min-h-screen flex-col">
|
||||
<div class="flex items-center justify-center border-b border-border px-3 py-2">
|
||||
<p class="text-xs text-muted-foreground">{statusText}</p>
|
||||
</div>
|
||||
<div class="bg-black flex-1">
|
||||
{#if streamUrl}
|
||||
<iframe
|
||||
src={streamUrl}
|
||||
title="Android session"
|
||||
class="h-full w-full border-0"
|
||||
allow="autoplay; fullscreen"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-sm text-white/70">Waiting for stream...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
21
apps/frontend/src/routes/unauthorized/+page.svelte
Normal file
21
apps/frontend/src/routes/unauthorized/+page.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const reasonMap: Record<string, string> = {
|
||||
missing_token: "Missing access token",
|
||||
invalid: "Invalid access link",
|
||||
};
|
||||
|
||||
const reason = browser
|
||||
? (new URLSearchParams(window.location.search).get("reason") ?? "invalid")
|
||||
: "invalid";
|
||||
</script>
|
||||
|
||||
<main class="bg-background text-foreground flex min-h-screen items-center justify-center px-6">
|
||||
<section class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center">
|
||||
<h1 class="text-xl font-semibold">Access denied</h1>
|
||||
<p class="mt-3 text-sm text-muted-foreground">
|
||||
{reasonMap[reason] || "This session link is not valid."}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
@@ -1,8 +1,8 @@
|
||||
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
|
||||
import { PUBLIC_WS_SCRCPY_SVC_URL } from "$env/static/public";
|
||||
import AppWindow from "@lucide/svelte/icons/app-window";
|
||||
import { BellRingIcon, Link } from "@lucide/svelte";
|
||||
import UserCircle from "~icons/lucide/user-circle";
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
export type AppSidebarItem = {
|
||||
title: string;
|
||||
@@ -47,7 +47,7 @@ export const secondaryNavTree = [
|
||||
},
|
||||
] as AppSidebarItem[];
|
||||
|
||||
export const WS_SCRCPY_URL = PUBLIC_WS_SCRCPY_SVC_URL;
|
||||
export const WS_SCRCPY_URL = env.PUBLIC_WS_SCRCPY_SVC_URL;
|
||||
|
||||
export const COMPANY_NAME = "SaaS Template";
|
||||
export const WEBSITE_URL = "https://company.com";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import { linkVM } from "$lib/domains/link/link.vm.svelte";
|
||||
@@ -49,10 +50,18 @@
|
||||
if (success) resetForm();
|
||||
}
|
||||
|
||||
async function copyToken(token: string) {
|
||||
function buildPublicLink(token: string): string {
|
||||
const base = env.PUBLIC_FRONTEND_URL?.trim() || "/";
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
||||
const url = new URL(base, origin);
|
||||
url.searchParams.set("token", token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function copyLink(token: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
toast.success("Token copied to clipboard");
|
||||
await navigator.clipboard.writeText(buildPublicLink(token));
|
||||
toast.success("Link copied to clipboard");
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
@@ -145,7 +154,7 @@
|
||||
<button
|
||||
class="text-muted-foreground hover:text-foreground shrink-0"
|
||||
onclick={() =>
|
||||
copyToken(link.token)}
|
||||
copyLink(link.token)}
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -270,7 +279,7 @@
|
||||
<button
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
copyToken(link.token)}
|
||||
copyLink(link.token)}
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -69,3 +69,18 @@ Update rule:
|
||||
|
||||
- Re-condensed timeline from many micro-numbered entries into broader milestone groups.
|
||||
- Restored important implementation details while keeping the log substantially shorter and easier to scan.
|
||||
|
||||
### 9 — Frontend Session Routing + Live Revocation Polling
|
||||
|
||||
- Implemented token-gated frontend flow: `/` now validates + prepares link sessions and redirects to `/session` only after orchestrator preparation succeeds.
|
||||
- Added explicit failure routes for invalid access and terminated sessions (`/unauthorized`, `/session-ended`) with reason-based messaging.
|
||||
- Added session runtime polling (2s interval with forced query refresh + network failure tolerance) that ejects users immediately when link validity is lost.
|
||||
- Extended link resolve payload with server-built ws-scrcpy stream URL data so the session route can render the active stream without exposing raw device details in the URL.
|
||||
|
||||
### 10 — Frontend De-Branding Cleanup
|
||||
|
||||
- Removed temporary `IOTAM` label text from session loading and failure pages to keep user-facing frontend copy generic.
|
||||
|
||||
### 11 — Admin Link Copy Full URL
|
||||
|
||||
- Refactored admin links-page copy action to copy full public frontend access URLs (`PUBLIC_FRONTEND_URL?token=...`) instead of raw tokens.
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { errAsync, ResultAsync } from "neverthrow";
|
||||
import { nanoid } from "nanoid";
|
||||
import { db } from "@pkg/db";
|
||||
import { type Err } from "@pkg/result";
|
||||
import { FlowExecCtx } from "@core/flow.execution.context";
|
||||
import {
|
||||
CreateLink,
|
||||
Link,
|
||||
@@ -10,8 +5,13 @@ import {
|
||||
LinkWithDevice,
|
||||
UpdateLink,
|
||||
} from "./data";
|
||||
import { FlowExecCtx } from "@core/flow.execution.context";
|
||||
import { errAsync, ResultAsync } from "neverthrow";
|
||||
import { LinkRepository } from "./repository";
|
||||
import { type Err } from "@pkg/result";
|
||||
import { linkErrors } from "./errors";
|
||||
import { nanoid } from "nanoid";
|
||||
import { db } from "@pkg/db";
|
||||
|
||||
export class LinkController {
|
||||
constructor(private repo: LinkRepository) {}
|
||||
@@ -26,9 +26,12 @@ export class LinkController {
|
||||
|
||||
/**
|
||||
* Fetch a link by its URL token, including the joined device.
|
||||
* Used by apps/front to validate and resolve an incoming link.
|
||||
* Used by apps/frontend to validate and resolve an incoming link.
|
||||
*/
|
||||
getByToken(fctx: FlowExecCtx, token: string): ResultAsync<LinkWithDevice, Err> {
|
||||
getByToken(
|
||||
fctx: FlowExecCtx,
|
||||
token: string,
|
||||
): ResultAsync<LinkWithDevice, Err> {
|
||||
return this.repo.getByToken(fctx, token);
|
||||
}
|
||||
|
||||
@@ -36,7 +39,10 @@ export class LinkController {
|
||||
* Validate a token: must exist, be active, and not be expired.
|
||||
* Returns the resolved link+device on success.
|
||||
*/
|
||||
validate(fctx: FlowExecCtx, token: string): ResultAsync<LinkWithDevice, Err> {
|
||||
validate(
|
||||
fctx: FlowExecCtx,
|
||||
token: string,
|
||||
): ResultAsync<LinkWithDevice, Err> {
|
||||
return this.repo.getByToken(fctx, token).andThen((l) => {
|
||||
if (l.status !== LinkStatus.ACTIVE) {
|
||||
return errAsync(linkErrors.linkNotActive(fctx, token));
|
||||
|
||||
64
pnpm-lock.yaml
generated
64
pnpm-lock.yaml
generated
@@ -187,70 +187,6 @@ importers:
|
||||
specifier: ^4.0.15
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
apps/frontlegacy:
|
||||
dependencies:
|
||||
'@hono/node-server':
|
||||
specifier: ^1.19.9
|
||||
version: 1.19.9(hono@4.12.8)
|
||||
'@opentelemetry/api':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
'@opentelemetry/auto-instrumentations-node':
|
||||
specifier: ^0.70.1
|
||||
version: 0.70.1(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))
|
||||
'@opentelemetry/exporter-logs-otlp-proto':
|
||||
specifier: ^0.212.0
|
||||
version: 0.212.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/exporter-metrics-otlp-proto':
|
||||
specifier: ^0.212.0
|
||||
version: 0.212.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/exporter-trace-otlp-proto':
|
||||
specifier: ^0.212.0
|
||||
version: 0.212.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-logs':
|
||||
specifier: ^0.212.0
|
||||
version: 0.212.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-metrics':
|
||||
specifier: ^2.1.0
|
||||
version: 2.5.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-node':
|
||||
specifier: ^0.212.0
|
||||
version: 0.212.0(@opentelemetry/api@1.9.0)
|
||||
'@pkg/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db
|
||||
'@pkg/logger':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/logger
|
||||
'@pkg/logic':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/logic
|
||||
'@pkg/result':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/result
|
||||
'@pkg/settings':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/settings
|
||||
hono:
|
||||
specifier: ^4.12.8
|
||||
version: 4.12.8
|
||||
import-in-the-middle:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
valibot:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(typescript@5.9.3)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.3.2
|
||||
version: 25.5.0
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
apps/main:
|
||||
dependencies:
|
||||
'@opentelemetry/api':
|
||||
|
||||
Reference in New Issue
Block a user