diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2622c16a..64180c0f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,7 @@ for a Bazel LSP you can use the Bazel extension for VSCode and download [starpls ### Environment Variables -We advice you to use [direnv](https://direnv.net/) to load the `.envrc` file, which sets up a few environment variables needed for development. +We advise you to use [direnv](https://direnv.net/) to load the `.envrc` file, which sets up a few environment variables needed for development. ### Nix diff --git a/cloud/core/emails/templates/emails/register_with_email/html.stpl b/cloud/core/emails/templates/emails/register_with_email/html.stpl index 4fa907f79..854f25399 100644 --- a/cloud/core/emails/templates/emails/register_with_email/html.stpl +++ b/cloud/core/emails/templates/emails/register_with_email/html.stpl @@ -25,7 +25,7 @@ style="width: 310px; border-style: solid; border-width: thin; border-color: #E6DEDB; border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; padding: 24px; background-color: #FAF5F1;">
- Thanks for singing up for Scuffle! + Thanks for signing up for Scuffle!
diff --git a/cloud/core/emails/templates/emails/register_with_email/text.stpl b/cloud/core/emails/templates/emails/register_with_email/text.stpl index 3ffe888fb..f007760b8 100644 --- a/cloud/core/emails/templates/emails/register_with_email/text.stpl +++ b/cloud/core/emails/templates/emails/register_with_email/text.stpl @@ -1,4 +1,4 @@ -Thanks for singing up for Scuffle! Click the link below to access your Scuffle account. +Thanks for signing up for Scuffle! Click the link below to access your Scuffle account. <%= self.url %> The link is valid for <%= self.timeout_minutes %> minutes. diff --git a/cloud/dashboard/.env b/cloud/dashboard/.env index 1f40d343a..65b9712bb 100644 --- a/cloud/dashboard/.env +++ b/cloud/dashboard/.env @@ -1,7 +1,6 @@ # Default Public Variables # If you want to modify any of the values, create an .env.local file to override them. -PUBLIC_VITE_MSW_ENABLED=true PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA # The default developement deployment we use right now. PUBLIC_GRPC_BASE_URL=https://core.lennart.scuf.dev/ diff --git a/cloud/dashboard/eslint.config.mjs b/cloud/dashboard/eslint.config.mjs index 391c49075..91f5b3638 100644 --- a/cloud/dashboard/eslint.config.mjs +++ b/cloud/dashboard/eslint.config.mjs @@ -28,7 +28,6 @@ export default tsEslint.config( "**/build", "**/node_modules", "**/package", - "static/mockServiceWorker.js", ], }, ); diff --git a/cloud/dashboard/package.json b/cloud/dashboard/package.json index 3242c2a36..1441a2f4a 100644 --- a/cloud/dashboard/package.json +++ b/cloud/dashboard/package.json @@ -13,12 +13,13 @@ "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.4", "@protobuf-ts/grpcweb-transport": "^2.11.1", + "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "@scufflecloud/proto": "workspace:*", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", - "@tanstack/svelte-query": "^5.62.2", + "@tanstack/svelte-query": "^6.0.0", "@types/d3-geo": "^3.1.0", "@types/lodash": "^4.17.16", "d3-geo": "^3.1.1", @@ -30,8 +31,7 @@ "globals": "^16.0.0", "lodash": "^4.17.21", "melt": "^0.17.8", - "msw": "^2.7.3", - "svelte": "^5.16.0", + "svelte": "^5.39.8", "svelte-echarts": "^1.0.0", "svelte-eslint-parser": "^0.43.0", "svelte-turnstile": "^0.11.0", @@ -41,10 +41,5 @@ }, "dependenciesComments": { "svelte-eslint-parser": "New version has errors with `pnpm lint`. Can resolve later." - }, - "msw": { - "workerDirectory": [ - "static" - ] } } diff --git a/cloud/dashboard/src/app.d.ts b/cloud/dashboard/src/app.d.ts index d76242adf..082f115b8 100644 --- a/cloud/dashboard/src/app.d.ts +++ b/cloud/dashboard/src/app.d.ts @@ -7,6 +7,10 @@ declare global { // interface PageData {} // interface PageState {} // interface Platform {} + interface PageState { + loginMode?: import("$lib/types").LoginMode; + userEmail?: string; + } } } diff --git a/cloud/dashboard/src/components/settings-block.svelte b/cloud/dashboard/src/components/settings-block.svelte deleted file mode 100644 index 9d67f7a99..000000000 --- a/cloud/dashboard/src/components/settings-block.svelte +++ /dev/null @@ -1,318 +0,0 @@ - - -
-
-
- {#each [icon] as IconComponent, index (`icon-${index}`)} - - {/each} -
-
-

{title}

- {#if subtitle} - {subtitle} - {/if} -
-
-
- {#each cards as card (card.id)} -
-
-
-

{card.title}

- {#if card.status} - - {card.status.label} - - {/if} -
-
- - {#if card.description} -

{card.description}

- {/if} - - {#if card.customContent} -
- {@render card.customContent()} -
- {/if} - - {#if card.actions && card.actions.length > 0} -
- {#each card.actions as action (action.label)} - {#if action.variant === "toggle"} - - handleToggleChange(action, checked)} - size="medium" - /> - {:else} - - {/if} - {/each} -
- {/if} -
- {/each} -
-
- - diff --git a/cloud/dashboard/src/components/settings/settings-page.svelte b/cloud/dashboard/src/components/settings/settings-page.svelte deleted file mode 100644 index bf7aef7c7..000000000 --- a/cloud/dashboard/src/components/settings/settings-page.svelte +++ /dev/null @@ -1,282 +0,0 @@ - - -
- - - - - -
- - diff --git a/cloud/dashboard/src/components/streams/all-video-streams.svelte b/cloud/dashboard/src/components/streams/all-video-streams.svelte deleted file mode 100644 index ed96aec84..000000000 --- a/cloud/dashboard/src/components/streams/all-video-streams.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -
-
-
- - - - - -
-
-{#await streamedData} -
Loading...
-{:then resolvedStreams} - -{:catch error} -

{error.message}

-{/await} - - diff --git a/cloud/dashboard/src/components/streams/assets/assets-tab.svelte b/cloud/dashboard/src/components/streams/assets/assets-tab.svelte deleted file mode 100644 index 8d700196f..000000000 --- a/cloud/dashboard/src/components/streams/assets/assets-tab.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -
- -
- - diff --git a/cloud/dashboard/src/features/login-two-factor/createMfaWebauthnChallenge.ts b/cloud/dashboard/src/features/login-two-factor/createMfaWebauthnChallenge.ts new file mode 100644 index 000000000..a9151e4e9 --- /dev/null +++ b/cloud/dashboard/src/features/login-two-factor/createMfaWebauthnChallenge.ts @@ -0,0 +1,48 @@ +import { sessionsServiceClient, usersServiceClient } from "$lib/grpcClient"; +import { isWebauthnSupported, parseCredentialRequestOptions, serializeCredentialAssertionResponse } from "$lib/utils"; +import { getWebAuthnErrorMessage } from "../settings/utils"; + +export async function createMfaWebauthnChallenge(userId: string): Promise { + if (!isWebauthnSupported()) { + throw new Error("WebAuthn not supported on this browser"); + } + + const challengeCall = usersServiceClient.createWebauthnChallenge({ id: userId }); + const challengeStatus = await challengeCall.status; + + if (challengeStatus.code !== "OK") { + throw new Error(challengeStatus.detail || "Failed to initiate WebAuthn credential challenge"); + } + + const challengeResponse = await challengeCall.response; + + const publicKey = parseCredentialRequestOptions(challengeResponse.optionsJson); + + let credential: PublicKeyCredential | null = null; + + try { + credential = await navigator.credentials.get({ publicKey }) as PublicKeyCredential | null; + } catch (err) { + throw new Error(getWebAuthnErrorMessage(err)); + } + + if (!credential) { + throw new Error("No credential received from authenticator"); + } + + const responseJson = serializeCredentialAssertionResponse(credential); + + // Returns a usersession that we should just consume locally but we can do that later + + console.log("collected credential now validating for session"); + await sessionsServiceClient.validateMfaForUserSession({ + response: { + oneofKind: "webauthn", + webauthn: { + responseJson, + }, + }, + }).response; + + console.log("completely validation for session"); +} diff --git a/cloud/dashboard/src/features/login-two-factor/mfaChallengeMutations.ts b/cloud/dashboard/src/features/login-two-factor/mfaChallengeMutations.ts new file mode 100644 index 000000000..2098f6e6f --- /dev/null +++ b/cloud/dashboard/src/features/login-two-factor/mfaChallengeMutations.ts @@ -0,0 +1,17 @@ +import { authState } from "$lib/auth.svelte"; +import { withRpcErrorHandling } from "$lib/utils"; +import { createMutation } from "@tanstack/svelte-query"; +import { createMfaWebauthnChallenge } from "./createMfaWebauthnChallenge"; + +export function useCreateWebauthnChallenge(userId: string | undefined) { + return createMutation(() => ({ + mutationFn: () => + withRpcErrorHandling(async () => { + if (!userId) throw new Error("User not authenticated"); + return await createMfaWebauthnChallenge(userId); + }), + onSuccess: () => { + authState().reloadUserForMfa(); + }, + })); +} diff --git a/cloud/dashboard/src/features/login-two-factor/recovery-code-collapsible.svelte b/cloud/dashboard/src/features/login-two-factor/recovery-code-collapsible.svelte new file mode 100644 index 000000000..7f545351c --- /dev/null +++ b/cloud/dashboard/src/features/login-two-factor/recovery-code-collapsible.svelte @@ -0,0 +1,111 @@ + + +
+ + + {#if troubleshootCollapsible.open} +
+ +
+ {/if} +
+ + diff --git a/cloud/dashboard/src/features/login-two-factor/recovery-code-form.svelte b/cloud/dashboard/src/features/login-two-factor/recovery-code-form.svelte new file mode 100644 index 000000000..9d62ca66d --- /dev/null +++ b/cloud/dashboard/src/features/login-two-factor/recovery-code-form.svelte @@ -0,0 +1,180 @@ + + +
+ +

2FA Recovery

+
+

+ No 2FA device available?
Paste your backup code below. +

+ +{#if error} +
{error}
+{/if} + +
+
+ +
+ +
+ + diff --git a/cloud/dashboard/src/components/login/mfa-form.svelte b/cloud/dashboard/src/features/login-two-factor/totp-form.svelte similarity index 82% rename from cloud/dashboard/src/components/login/mfa-form.svelte rename to cloud/dashboard/src/features/login-two-factor/totp-form.svelte index 27a7204f5..860e4d41a 100644 --- a/cloud/dashboard/src/components/login/mfa-form.svelte +++ b/cloud/dashboard/src/features/login-two-factor/totp-form.svelte @@ -1,14 +1,20 @@
-

MFA Login

- Enter the 6-digit code from your 2FA authenticator app below. + Enter the 6-digit code from your 2FA authenticator app below

@@ -53,6 +56,18 @@ Continue {/if} +{#if onModeChange} + + +{/if} + diff --git a/cloud/dashboard/src/features/login-two-factor/types.ts b/cloud/dashboard/src/features/login-two-factor/types.ts new file mode 100644 index 000000000..1bbc9cc9a --- /dev/null +++ b/cloud/dashboard/src/features/login-two-factor/types.ts @@ -0,0 +1,6 @@ +export const DEFAULT_TWO_FACTOR_MODE: TwoFactorMode = "webauthn"; + +export type TwoFactorMode = + | "webauthn" + | "totp" + | "recovery-code"; diff --git a/cloud/dashboard/src/features/login-two-factor/web-authnn-form.svelte b/cloud/dashboard/src/features/login-two-factor/web-authnn-form.svelte new file mode 100644 index 000000000..906291ebf --- /dev/null +++ b/cloud/dashboard/src/features/login-two-factor/web-authnn-form.svelte @@ -0,0 +1,127 @@ + + +
+

Authentication

+
+

+ Plug in your security key and touch it when prompted to continue. +

+ +{#if webauthnMutation.isError} + +{/if} +{#if onToptModeChange} + + +{/if} + +
+ + + diff --git a/cloud/dashboard/src/features/login/authMutations.ts b/cloud/dashboard/src/features/login/authMutations.ts new file mode 100644 index 000000000..b6f03fb99 --- /dev/null +++ b/cloud/dashboard/src/features/login/authMutations.ts @@ -0,0 +1,106 @@ +// lib/auth/mutations.ts +import { goto } from "$app/navigation"; +import { authState } from "$lib/auth.svelte"; +import { sessionsServiceClient } from "$lib/grpcClient"; +import { base64urlToArrayBuffer, withRpcErrorHandling } from "$lib/utils"; +import { CaptchaProvider } from "@scufflecloud/proto/scufflecloud/core/v1/common.js"; +import { createMutation } from "@tanstack/svelte-query"; + +interface CompleteGoogleLoginParams { + code: string; + state: string; +} + +interface SendMagicLinkParams { + email: string; + captchaToken: string; +} + +interface CompleteMagicLinkParams { + code: string; +} + +export function useInitiateGoogleLogin() { + return createMutation(() => ({ + mutationFn: () => + withRpcErrorHandling(async () => { + const device = await authState().getDeviceOrInit(); + const response = await sessionsServiceClient.loginWithGoogle({ device }).response; + window.location.href = response.authorizationUrl; + }), + })); +} + +export function useCompleteGoogleLogin() { + return createMutation(() => ({ + mutationFn: ({ code, state }: CompleteGoogleLoginParams) => + withRpcErrorHandling(async () => { + const device = await authState().getDeviceOrInit(); + const response = await sessionsServiceClient.completeLoginWithGoogle({ + code, + state, + device, + }).response; + + if (!response.newUserSessionToken) { + throw new Error("No session token received"); + } + + await authState().handleNewUserSessionToken(response.newUserSessionToken); + + if (response.newUserSessionToken?.sessionMfaPending) { + goto("/mfa"); + } else { + goto("/"); + } + }), + })); +} + +export function useSendMagicLink() { + return createMutation(() => ({ + mutationFn: ({ email, captchaToken }: SendMagicLinkParams) => + withRpcErrorHandling(async () => { + if (!email || !captchaToken) { + throw new Error("Email and captcha token are required"); + } + + await sessionsServiceClient.loginWithMagicLink({ + captcha: { + provider: CaptchaProvider.TURNSTILE, + token: captchaToken, + }, + email, + }).response; + + console.log("Magic link sent successfully to:", email); + }), + })); +} + +export function useCompleteMagicLink() { + return createMutation(() => ({ + mutationFn: ({ code }: CompleteMagicLinkParams) => + withRpcErrorHandling(async () => { + const device = await authState().getDeviceOrInit(); + const codeBuffer = base64urlToArrayBuffer(code); + + const response = await sessionsServiceClient.completeLoginWithMagicLink({ + code: new Uint8Array(codeBuffer), + device, + }).response; + + if (!response) { + throw new Error("No session token received"); + } + + await authState().handleNewUserSessionToken(response); + + if (response?.sessionMfaPending) { + goto("/mfa"); + } else { + goto("/"); + } + }), + })); +} diff --git a/cloud/dashboard/src/components/login/forgot-password-form.svelte b/cloud/dashboard/src/features/login/forgot-password-form.svelte similarity index 100% rename from cloud/dashboard/src/components/login/forgot-password-form.svelte rename to cloud/dashboard/src/features/login/forgot-password-form.svelte diff --git a/cloud/dashboard/src/components/login/login-footer.svelte b/cloud/dashboard/src/features/login/login-footer.svelte similarity index 100% rename from cloud/dashboard/src/components/login/login-footer.svelte rename to cloud/dashboard/src/features/login/login-footer.svelte diff --git a/cloud/dashboard/src/components/login/login-header.svelte b/cloud/dashboard/src/features/login/login-header.svelte similarity index 100% rename from cloud/dashboard/src/components/login/login-header.svelte rename to cloud/dashboard/src/features/login/login-header.svelte diff --git a/cloud/dashboard/src/features/login/login-layout.svelte b/cloud/dashboard/src/features/login/login-layout.svelte new file mode 100644 index 000000000..b22872c4e --- /dev/null +++ b/cloud/dashboard/src/features/login/login-layout.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/cloud/dashboard/src/components/login/login-page.svelte b/cloud/dashboard/src/features/login/login-page.svelte similarity index 64% rename from cloud/dashboard/src/components/login/login-page.svelte rename to cloud/dashboard/src/features/login/login-page.svelte index 269c4bf15..ae63649af 100644 --- a/cloud/dashboard/src/components/login/login-page.svelte +++ b/cloud/dashboard/src/features/login/login-page.svelte @@ -1,13 +1,14 @@ - +
{/if} diff --git a/cloud/dashboard/src/components/streams/events/chart.svelte b/cloud/dashboard/src/lib/components/streams/events/chart.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/events/chart.svelte rename to cloud/dashboard/src/lib/components/streams/events/chart.svelte diff --git a/cloud/dashboard/src/components/streams/events/events-legend.svelte b/cloud/dashboard/src/lib/components/streams/events/events-legend.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/events/events-legend.svelte rename to cloud/dashboard/src/lib/components/streams/events/events-legend.svelte diff --git a/cloud/dashboard/src/components/streams/events/events-list.svelte b/cloud/dashboard/src/lib/components/streams/events/events-list.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/events/events-list.svelte rename to cloud/dashboard/src/lib/components/streams/events/events-list.svelte diff --git a/cloud/dashboard/src/components/streams/events/events-tab.svelte b/cloud/dashboard/src/lib/components/streams/events/events-tab.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/events/events-tab.svelte rename to cloud/dashboard/src/lib/components/streams/events/events-tab.svelte diff --git a/cloud/dashboard/src/components/streams/events/sample-data.ts b/cloud/dashboard/src/lib/components/streams/events/sample-data.ts similarity index 100% rename from cloud/dashboard/src/components/streams/events/sample-data.ts rename to cloud/dashboard/src/lib/components/streams/events/sample-data.ts diff --git a/cloud/dashboard/src/components/streams/events/shape-renderers.ts b/cloud/dashboard/src/lib/components/streams/events/shape-renderers.ts similarity index 100% rename from cloud/dashboard/src/components/streams/events/shape-renderers.ts rename to cloud/dashboard/src/lib/components/streams/events/shape-renderers.ts diff --git a/cloud/dashboard/src/components/streams/events/stream-select.svelte b/cloud/dashboard/src/lib/components/streams/events/stream-select.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/events/stream-select.svelte rename to cloud/dashboard/src/lib/components/streams/events/stream-select.svelte diff --git a/cloud/dashboard/src/components/streams/events/types.ts b/cloud/dashboard/src/lib/components/streams/events/types.ts similarity index 100% rename from cloud/dashboard/src/components/streams/events/types.ts rename to cloud/dashboard/src/lib/components/streams/events/types.ts diff --git a/cloud/dashboard/src/components/streams/header.svelte b/cloud/dashboard/src/lib/components/streams/header.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/header.svelte rename to cloud/dashboard/src/lib/components/streams/header.svelte diff --git a/cloud/dashboard/src/components/streams/new-stream.svelte b/cloud/dashboard/src/lib/components/streams/new-stream.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/new-stream.svelte rename to cloud/dashboard/src/lib/components/streams/new-stream.svelte diff --git a/cloud/dashboard/src/components/streams/overview/overview-tab.svelte b/cloud/dashboard/src/lib/components/streams/overview/overview-tab.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/overview/overview-tab.svelte rename to cloud/dashboard/src/lib/components/streams/overview/overview-tab.svelte diff --git a/cloud/dashboard/src/components/streams/overview/video-header.svelte b/cloud/dashboard/src/lib/components/streams/overview/video-header.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/overview/video-header.svelte rename to cloud/dashboard/src/lib/components/streams/overview/video-header.svelte diff --git a/cloud/dashboard/src/components/streams/settings/settings-tab.svelte b/cloud/dashboard/src/lib/components/streams/settings/settings-tab.svelte similarity index 83% rename from cloud/dashboard/src/components/streams/settings/settings-tab.svelte rename to cloud/dashboard/src/lib/components/streams/settings/settings-tab.svelte index 8d700196f..5cf01b59a 100644 --- a/cloud/dashboard/src/components/streams/settings/settings-tab.svelte +++ b/cloud/dashboard/src/lib/components/streams/settings/settings-tab.svelte @@ -1,6 +1,6 @@
diff --git a/cloud/dashboard/src/components/streams/stream-detail-tabs.svelte b/cloud/dashboard/src/lib/components/streams/stream-detail-tabs.svelte similarity index 100% rename from cloud/dashboard/src/components/streams/stream-detail-tabs.svelte rename to cloud/dashboard/src/lib/components/streams/stream-detail-tabs.svelte diff --git a/cloud/dashboard/src/components/streams/streams-table.svelte b/cloud/dashboard/src/lib/components/streams/streams-table.svelte similarity index 92% rename from cloud/dashboard/src/components/streams/streams-table.svelte rename to cloud/dashboard/src/lib/components/streams/streams-table.svelte index 9294d78fa..e8b8e02e6 100644 --- a/cloud/dashboard/src/components/streams/streams-table.svelte +++ b/cloud/dashboard/src/lib/components/streams/streams-table.svelte @@ -4,7 +4,7 @@ import StreamStatusPill from "$lib/shared-components/stream-status-pill.svelte"; import type { VideoStream } from "./types"; - export let streams: VideoStream[]; + let { streams }: { streams: VideoStream[] } = $props(); // Map stream statuses to text that gets displayed here const iconMap = { @@ -25,18 +25,14 @@ {#each streams as stream (stream.id)} + {@const StatusIcon = iconMap[stream.status]}
- + + import { goto } from "$app/navigation"; + import { authState } from "$lib/auth.svelte"; import IconConfigureTab from "$lib/images/icon-configure-tab.svelte"; import Search from "$lib/images/search.svelte"; import { onMount } from "svelte"; @@ -122,9 +124,15 @@
- +