Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions web/apps/dashboard/app/auth/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PENDING_SESSION_COOKIE,
type PendingTurnstileResponse,
type SignInViaOAuthOptions,
UNKEY_LAST_ORG_COOKIE,
type UserData,
type VerificationResult,
errorMessages,
Expand Down Expand Up @@ -372,6 +373,7 @@ export async function completeOrgSelection(
// Ignore cookie setting errors
}
}
// Don't clear pending session on error - let user try again or close modal

return result;
}
Expand Down Expand Up @@ -488,6 +490,14 @@ export async function verifyTurnstileAndRetry(params: {
};
}

/**
* Clear pending authentication state when user cancels org selection
*/
export async function clearPendingAuth(): Promise<void> {
(await cookies()).delete(PENDING_SESSION_COOKIE);
(await cookies()).delete(UNKEY_LAST_ORG_COOKIE);
}

/**
* Check if a pending session exists (for workspace selection flow)
* This is needed because PENDING_SESSION_COOKIE is HttpOnly and not accessible from client
Expand Down
15 changes: 12 additions & 3 deletions web/apps/dashboard/app/auth/hooks/useSignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,19 @@ export function useSignIn() {
if (orgsParam) {
try {
parsedOrgs = JSON.parse(decodeURIComponent(orgsParam));
setOrgs(parsedOrgs);
if (Array.isArray(parsedOrgs)) {
setOrgs(parsedOrgs);
} else {
// Invalid format, clear orgs
setOrgs([]);
}
} catch (_err) {
setError("Failed to load organizations");
// Invalid JSON, clear orgs and don't show error
setOrgs([]);
}
} else {
// No orgs param, clear orgs
setOrgs([]);
}

// Check for pending session cookie
Expand All @@ -83,7 +92,7 @@ export function useSignIn() {
};

checkAuthStatus();
}, [searchParams, setError]);
}, [searchParams]);

const handleSignInViaEmail = async (email: string) => {
try {
Expand Down
34 changes: 25 additions & 9 deletions web/apps/dashboard/app/auth/sign-in/[[...sign-in]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
"use client";

import { FadeIn } from "@/components/landing/fade-in";
import { getCookie } from "@/lib/auth/cookies";
import { PENDING_SESSION_COOKIE, UNKEY_LAST_ORG_COOKIE } from "@/lib/auth/types";
import { deleteCookie, getCookie } from "@/lib/auth/cookies";
import {
AuthErrorCode,
PENDING_SESSION_COOKIE,
UNKEY_LAST_ORG_COOKIE,
errorMessages,
} from "@/lib/auth/types";
import { ArrowRight } from "@unkey/icons";
import { Empty, Loading } from "@unkey/ui";
import Link from "next/link";
Expand Down Expand Up @@ -77,15 +82,31 @@ function SignInContent() {
completeOrgSelection(lastUsedOrgId)
.then((result) => {
if (!result.success) {
// Auto-selection failed - clear last used workspace to prevent retry loop
deleteCookie(UNKEY_LAST_ORG_COOKIE).catch(() => {
// Ignore cookie deletion errors
});

setError(result.message);
setIsLoading(false);
setIsAutoSelecting(false);

// If session expired, the pending auth was already cleared
// Just show the error and let user see org selector
if (result.code === AuthErrorCode.PENDING_SESSION_EXPIRED) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems pointless, why are we doing an if check if we don't run any conditional code?

// Session expired error already shown, user will see sign-in form
}
return;
}
// On success, redirect to the dashboard
router.push(result.redirectTo);
})
.catch((_err) => {
// Clear last used workspace on error
deleteCookie(UNKEY_LAST_ORG_COOKIE).catch(() => {
// Ignore cookie deletion errors
});

setError("Failed to automatically sign in. Please select your workspace.");
setIsLoading(false);
setIsAutoSelecting(false);
Expand Down Expand Up @@ -150,7 +171,7 @@ function SignInContent() {
const checkSessionValidity = async () => {
const pendingSession = await getCookie(PENDING_SESSION_COOKIE);
if (!pendingSession) {
setError("Your session has expired. Please sign in again.");
setError(errorMessages[AuthErrorCode.PENDING_SESSION_EXPIRED]);
// Clear the orgs query parameter to reset to sign-in form
router.push("/auth/sign-in");
}
Expand Down Expand Up @@ -178,14 +199,9 @@ function SignInContent() {
);
}

const handleOrgSelectorClose = () => {
// When user closes the org selector, navigate back to clean sign-in page
router.push("/auth/sign-in");
};

// Only show org selector if we have pending auth and we're not actively auto-selecting
return hasPendingAuth && !isAutoSelecting ? (
<OrgSelector organizations={orgs} lastOrgId={lastUsedOrgId} onClose={handleOrgSelectorClose} />
<OrgSelector organizations={orgs} lastOrgId={lastUsedOrgId} />
) : (
<div className="flex flex-col gap-10">
{accountNotFound && (
Expand Down
79 changes: 38 additions & 41 deletions web/apps/dashboard/app/auth/sign-in/org-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import type { Organization } from "@/lib/auth/types";
import { AuthErrorCode } from "@/lib/auth/types";
import {
Button,
DialogContainer,
Expand All @@ -13,32 +14,24 @@ import {
SelectValue,
toast,
} from "@unkey/ui";
import { useRouter } from "next/navigation";
import type React from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { completeOrgSelection } from "../actions";
import { useCallback, useContext, useMemo, useState } from "react";
import { clearPendingAuth, completeOrgSelection } from "../actions";
import { SignInContext } from "../context/signin-context";

interface OrgSelectorProps {
organizations: Organization[];
lastOrgId?: string;
onClose?: () => void;
}

export const OrgSelector: React.FC<OrgSelectorProps> = ({ organizations, lastOrgId, onClose }) => {
export const OrgSelector: React.FC<OrgSelectorProps> = ({ organizations, lastOrgId }) => {
const context = useContext(SignInContext);
if (!context) {
throw new Error("OrgSelector must be used within SignInProvider");
}
const { setError } = context;
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [clientReady, setClientReady] = useState(false);
const [selectedOrgId, setSelectedOrgId] = useState("");
const [hasInitialized, setHasInitialized] = useState(false);
// Set client ready after hydration
useEffect(() => {
setClientReady(true);
}, []);
const router = useRouter();

const sortedOrgs = useMemo(() => {
// Sort: recently created first (as proxy for recently used until we track that)
Expand All @@ -49,6 +42,24 @@ export const OrgSelector: React.FC<OrgSelectorProps> = ({ organizations, lastOrg
});
}, [organizations]);

// Initialize state directly - no effect needed
const initialOrgId =
lastOrgId && sortedOrgs.some((org) => org.id === lastOrgId)
? lastOrgId
: sortedOrgs[0]?.id || "";

const [isOpen, setIsOpen] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [selectedOrgId, setSelectedOrgId] = useState(initialOrgId);

const handleClose = useCallback(async () => {
// Close modal immediately to prevent flash
setIsOpen(false);
// Clear pending auth state and redirect
await clearPendingAuth();
router.push("/auth/sign-in");
}, [router]);

const submit = useCallback(
async (orgId: string): Promise<boolean> => {
if (isLoading || !orgId) {
Expand All @@ -63,11 +74,17 @@ export const OrgSelector: React.FC<OrgSelectorProps> = ({ organizations, lastOrg
setError(result.message);
setIsLoading(false);
toast.error(result.message);

// If session expired, redirect to sign-in to clear stale state
if (result.code === AuthErrorCode.PENDING_SESSION_EXPIRED) {
router.push("/auth/sign-in");
}

return false;
}

// On success, redirect to the dashboard
window.location.href = result.redirectTo;
router.push(result.redirectTo);
return true;
} catch (error) {
const errorMessage =
Expand All @@ -82,43 +99,23 @@ export const OrgSelector: React.FC<OrgSelectorProps> = ({ organizations, lastOrg
return false;
}
},
[isLoading, setError],
[isLoading, setError, router],
);

const handleSubmit = useCallback(async () => {
await submit(selectedOrgId);
}, [submit, selectedOrgId]);

// Initialize org selector when client is ready
useEffect(() => {
if (!clientReady || hasInitialized) {
return;
}

// Pre-select the last used org if it exists in the list, otherwise first org
const preselectedOrgId =
lastOrgId && sortedOrgs.some((org) => org.id === lastOrgId)
? lastOrgId
: sortedOrgs[0]?.id || "";

setSelectedOrgId(preselectedOrgId);
setIsOpen(true); // Always show the modal for manual selection
setHasInitialized(true);
}, [clientReady, sortedOrgs, lastOrgId, hasInitialized]);

return (
<DialogContainer
className="dark bg-black"
isOpen={clientReady && isOpen}
isOpen={isOpen}
onOpenChange={(open) => {
if (!isLoading) {
setIsOpen(open);
// If dialog is being closed, notify parent
if (!open && onClose) {
onClose();
}
if (!open && !isLoading) {
handleClose();
}
}}
preventOutsideClose={true}
title="Select your workspace"
footer={
<div className="flex items-center justify-center text-sm w-full text-content-subtle">
Expand All @@ -133,8 +130,8 @@ export const OrgSelector: React.FC<OrgSelectorProps> = ({ organizations, lastOrg
<div className="flex flex-col items-center gap-4 text-center">
<h3 className="text-lg font-medium text-content">No workspaces found</h3>
<p className="text-sm text-content-subtle max-w-md">
You don't have access to any workspaces. Please contact your administrator or create
a new workspace.
You don&apos;t have access to any workspaces. Please contact your administrator or
create a new workspace.
</p>
<div className="flex flex-col gap-2 w-full max-w-sm">
<Button
Expand Down
12 changes: 12 additions & 0 deletions web/apps/dashboard/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ const eslintConfig = [
{
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"],
},
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
];

export default eslintConfig;
3 changes: 1 addition & 2 deletions web/apps/dashboard/lib/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,7 @@ export const errorMessages: Record<AuthErrorCode, string> = {
"Please choose a workspace to continue authentication.",
[AuthErrorCode.EMAIL_VERIFICATION_REQUIRED]:
"Email address not verified. Please check your email for a verification code.",
[AuthErrorCode.PENDING_SESSION_EXPIRED]:
"Pending Authentication has expired. Please sign-in again.",
[AuthErrorCode.PENDING_SESSION_EXPIRED]: "Your session has expired. Please sign in again.",
[AuthErrorCode.RATE_ERROR]: "Limited OTP attempts",
[AuthErrorCode.RADAR_BLOCKED]:
"Unable to complete request due to suspicious activity. Please contact support@unkey.com if you believe this is an error.",
Expand Down
Loading