Skip to content

Commit 1c72e93

Browse files
Auto merge of #640 - lucas/finish-auth-flows, r=lennartkloock
finish totp auth flows finish 2fa totp flows and cleanup login flow. Redirect seems to be only for server components so changed redirects to use `goto` and works well and fixes issues with the preview link Uusers need to be able to escape the 2fa state and still return to the login screen. Currently they will always be stuck on the `/mfa` page regardless of navigation. This PR updates this so that the base "unauthenticated" pages can still be accessed. A user who is "half-authenticated" should not have to click on a logout button to initiate a login with a different account For example - - a successful magic link navigates you to the 2fa page - deciding not to enter 2fa, user visits login page again and attempts to login with magic link again - User should be able to successfully use a different login or instantiate a new login successfully to enter the app (old userSession can be revoked if desired) So now routing to "/" will only default route to "/mfa" on internal route access. All external routes will now still be accessible <!-- Thank you for your Pull Request. Please provide a short description of your changes above. Bug fixes and new features should include tests. Contributors guide: https://github.com/ScuffleCloud/.github/blob/main/CONTRIBUTING.md --> Requested-by: lucassshanks <[email protected]> Reviewed-by: lennartkloock <[email protected]>
2 parents c722170 + a43f051 commit 1c72e93

File tree

76 files changed

+1598
-903
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+1598
-903
lines changed

cloud/core/src/operations/organization_invitations.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ impl<G: core_traits::Global> Operation<G>
125125
}
126126

127127
impl<G: core_traits::Global> Operation<G>
128-
for tonic::Request<pb::scufflecloud::core::v1::ListOrgnizationInvitesByUserRequest>
128+
for tonic::Request<pb::scufflecloud::core::v1::ListOrganizationInvitesByUserRequest>
129129
{
130130
type Principal = User;
131131
type Resource = User;

cloud/core/src/services/organization_invitations.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ impl<G: core_traits::Global>
1919
Operation::<G>::run(req).await.map(tonic::Response::new)
2020
}
2121

22-
async fn list_orgnization_invites_by_user(
22+
async fn list_organization_invites_by_user(
2323
&self,
24-
req: tonic::Request<pb::scufflecloud::core::v1::ListOrgnizationInvitesByUserRequest>,
24+
req: tonic::Request<pb::scufflecloud::core::v1::ListOrganizationInvitesByUserRequest>,
2525
) -> Result<tonic::Response<pb::scufflecloud::core::v1::OrganizationInvitationList>, tonic::Status> {
2626
Operation::<G>::run(req).await.map(tonic::Response::new)
2727
}

cloud/dashboard/src/features/login-two-factor/createMfaWebauthnChallenge.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { sessionsServiceClient, usersServiceClient } from "$lib/grpcClient";
22
import { isWebauthnSupported, parseCredentialRequestOptions, serializeCredentialAssertionResponse } from "$lib/utils";
3-
import { getWebAuthnErrorMessage } from "../settings/utils";
3+
import { getWebAuthnErrorMessage } from "../settings/manage-two-factor/utils";
44

55
export async function createMfaWebauthnChallenge(userId: string): Promise<void> {
66
if (!isWebauthnSupported()) {
@@ -32,8 +32,6 @@ export async function createMfaWebauthnChallenge(userId: string): Promise<void>
3232

3333
const responseJson = serializeCredentialAssertionResponse(credential);
3434

35-
// Returns a usersession that we should just consume locally but we can do that later
36-
3735
console.log("collected credential now validating for session");
3836
await sessionsServiceClient.validateMfaForUserSession({
3937
response: {
@@ -43,6 +41,5 @@ export async function createMfaWebauthnChallenge(userId: string): Promise<void>
4341
},
4442
},
4543
}).response;
46-
4744
console.log("completely validation for session");
4845
}

cloud/dashboard/src/features/login-two-factor/mfaChallengeMutations.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { goto } from "$app/navigation";
12
import { authState } from "$lib/auth.svelte";
3+
import { sessionsServiceClient } from "$lib/grpcClient";
24
import { withRpcErrorHandling } from "$lib/utils";
35
import { createMutation } from "@tanstack/svelte-query";
46
import { createMfaWebauthnChallenge } from "./createMfaWebauthnChallenge";
@@ -10,8 +12,57 @@ export function useCreateWebauthnChallenge(userId: string | undefined) {
1012
if (!userId) throw new Error("User not authenticated");
1113
return await createMfaWebauthnChallenge(userId);
1214
}),
15+
onSuccess: async () => {
16+
await authState().reloadUserForMfa();
17+
goto("/");
18+
},
19+
}));
20+
}
21+
22+
export function useValidateMfaTotp() {
23+
return createMutation(() => ({
24+
mutationFn: (totpCode: string) =>
25+
withRpcErrorHandling(async () => {
26+
if (!totpCode || totpCode.trim().length === 0) {
27+
throw new Error("TOTP code is required");
28+
}
29+
30+
// Validate format
31+
if (!/^\d{6}$/.test(totpCode.trim())) {
32+
throw new Error("Invalid TOTP code format");
33+
}
34+
35+
await sessionsServiceClient.validateMfaForUserSession({
36+
response: {
37+
oneofKind: "totp",
38+
totp: {
39+
code: totpCode.trim(),
40+
},
41+
},
42+
}).response;
43+
}),
1344
onSuccess: () => {
1445
authState().reloadUserForMfa();
46+
goto("/");
1547
},
1648
}));
1749
}
50+
51+
export function useValidateMfaRecoveryCode() {
52+
return createMutation(() => ({
53+
mutationFn: (recoveryCode: string) =>
54+
withRpcErrorHandling(async () => {
55+
if (!recoveryCode || recoveryCode.trim().length === 0) {
56+
throw new Error("Recovery code is required");
57+
}
58+
await sessionsServiceClient.validateMfaForUserSession({
59+
response: {
60+
oneofKind: "recoveryCode",
61+
recoveryCode: {
62+
code: recoveryCode.trim(),
63+
},
64+
},
65+
}).response;
66+
}),
67+
}));
68+
}

cloud/dashboard/src/features/login-two-factor/recovery-code-collapsible.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343

4444
<style>
4545
.troubleshoot-section {
46+
margin-top: 1rem;
4647
width: 100%;
4748
}
4849
Lines changed: 33 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,33 @@
11
<script lang="ts">
2-
import {
3-
rpcErrorToString,
4-
sessionsServiceClient,
5-
} from "$lib/grpcClient";
6-
import IconArrowLeft from "$lib/images/icon-arrow-left.svelte";
7-
import { type RpcError } from "@protobuf-ts/runtime-rpc";
2+
import LoginFormTitle from "$features/login/login-form-title.svelte";
3+
import InlineNotification from "$lib/components/inline-notification.svelte";
4+
import { useValidateMfaRecoveryCode } from "./mfaChallengeMutations";
85
96
interface Props {
107
onBack: () => void;
118
}
129
1310
let { onBack }: Props = $props();
1411
15-
let loading = $state(false);
16-
let error = $state<string | null>(null);
12+
const validateMfaRecoveryCodeMutation =
13+
useValidateMfaRecoveryCode();
1714
1815
async function handleSubmit(event: SubmitEvent): Promise<void> {
1916
event.preventDefault();
2017
const formData = new FormData(event.target as HTMLFormElement);
2118
const recoveryCode = (formData.get("recovery-code") as string)
22-
.trim();
19+
?.trim();
20+
if (!recoveryCode) return;
2321
24-
if (!recoveryCode) {
25-
return;
26-
}
27-
28-
loading = true;
29-
error = null;
30-
31-
try {
32-
const validateCall = sessionsServiceClient
33-
.validateMfaForUserSession({
34-
response: {
35-
oneofKind: "recoveryCode",
36-
recoveryCode: {
37-
code: recoveryCode.trim(),
38-
},
39-
},
40-
});
41-
await validateCall.status;
42-
} catch (err) {
43-
const errorText = rpcErrorToString(err as RpcError);
44-
// TODO: Remove later after UI on how we want to show errors
45-
console.log(errorText);
46-
error = "Failed to validate recovery code";
47-
} finally {
48-
loading = false;
49-
}
22+
validateMfaRecoveryCodeMutation.mutate(recoveryCode);
5023
}
5124
</script>
5225

53-
<div class="header">
54-
<button type="button" onclick={onBack} class="back-button">
55-
<IconArrowLeft />
56-
</button>
57-
<h1 class="title">2FA Recovery</h1>
58-
</div>
26+
<LoginFormTitle title="2FA Recovery" {onBack} />
5927
<p class="subtitle">
6028
No 2FA device available? <br>Paste your backup code below.
6129
</p>
6230

63-
{#if error}
64-
<div class="error-message">{error}</div>
65-
{/if}
66-
6731
<form onsubmit={handleSubmit} class="recovery-form">
6832
<div class="form-group">
6933
<input
@@ -72,51 +36,33 @@
7236
id="recovery-code"
7337
class="form-input"
7438
placeholder="Enter your recovery code"
75-
disabled={loading}
39+
disabled={validateMfaRecoveryCodeMutation.isPending}
7640
required
7741
/>
7842
</div>
79-
<button type="submit" class="btn-primary" disabled={loading}>
80-
{loading ? "Verifying..." : "Continue"}
43+
{#if validateMfaRecoveryCodeMutation.isError}
44+
<div class="error-notification">
45+
<InlineNotification
46+
type="error"
47+
message={validateMfaRecoveryCodeMutation.error?.message
48+
|| "Failed to validate recovery code"}
49+
/>
50+
</div>
51+
{/if}
52+
<button
53+
type="submit"
54+
class="btn-primary"
55+
disabled={validateMfaRecoveryCodeMutation.isPending}
56+
>
57+
{
58+
validateMfaRecoveryCodeMutation.isPending
59+
? "Verifying..."
60+
: "Continue"
61+
}
8162
</button>
8263
</form>
8364

8465
<style>
85-
.header {
86-
display: flex;
87-
align-items: center;
88-
position: relative;
89-
margin-bottom: 2rem;
90-
}
91-
92-
.back-button {
93-
background: none;
94-
border: none;
95-
color: #6b7280;
96-
cursor: pointer;
97-
font-size: 0.875rem;
98-
padding: 0;
99-
position: absolute;
100-
left: 0;
101-
top: 50%;
102-
transform: translateY(-50%);
103-
display: flex;
104-
align-items: center;
105-
gap: 0.25rem;
106-
}
107-
108-
.back-button:hover {
109-
color: #374151;
110-
}
111-
112-
.title {
113-
font-size: 1.5rem;
114-
font-weight: 600;
115-
margin: 0 auto;
116-
text-align: center;
117-
flex: 1;
118-
}
119-
12066
.subtitle {
12167
font-size: 1rem;
12268
color: #272626;
@@ -177,4 +123,8 @@
177123
.btn-primary:hover:not(:disabled) {
178124
background: #d97706;
179125
}
126+
127+
.error-notification {
128+
margin-bottom: 1.25rem;
129+
}
180130
</style>

0 commit comments

Comments
 (0)