successful log in loop poc

This commit is contained in:
miloschwartz
2025-04-13 17:57:27 -04:00
parent 7556a59e11
commit 53be2739bb
37 changed files with 789 additions and 474 deletions

View File

@@ -24,7 +24,13 @@ import { useUserContext } from "@app/hooks/useUserContext";
export type UserRow = {
id: string;
email: string;
email: string | null;
displayUsername: string | null;
username: string;
name: string | null;
idpId: number | null;
idpName: string;
type: string;
status: string;
role: string;
isOwner: boolean;
@@ -82,7 +88,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
Manage User
</DropdownMenuItem>
</Link>
{userRow.email !== user?.email && (
{userRow.username !==
user?.username && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
@@ -108,7 +115,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}
},
{
accessorKey: "email",
accessorKey: "displayUsername",
header: ({ column }) => {
return (
<Button
@@ -117,14 +124,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Email
Username
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "status",
accessorKey: "idpName",
header: ({ column }) => {
return (
<Button
@@ -133,7 +140,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Status
Identity Provider
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -185,7 +192,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
<Button
variant={"outlinePrimary"}
className="ml-2"
>
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
@@ -239,7 +249,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<div className="space-y-4">
<p>
Are you sure you want to remove{" "}
<b>{selectedUser?.email}</b> from the organization?
<b>
{selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username}
</b>{" "}
from the organization?
</p>
<p>
@@ -250,14 +265,19 @@ export default function UsersTable({ users: u }: UsersTableProps) {
</p>
<p>
To confirm, please type the email address of the
user below.
To confirm, please type the name of the of the user
below.
</p>
</div>
}
buttonText="Confirm Remove User"
onConfirm={removeUser}
string={selectedUser?.email ?? ""}
string={
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
}
title="Remove User from Organization"
/>

View File

@@ -70,7 +70,13 @@ export default async function UsersPage(props: UsersPageProps) {
const userRows: UserRow[] = users.map((user) => {
return {
id: user.id,
username: user.username,
displayUsername: user.email || user.name || user.username,
name: user.name,
email: user.email,
type: user.type,
idpId: user.idpId,
idpName: user.idpName || "Internal",
status: "Confirmed",
role: user.isOwner ? "Owner" : user.roleName || "Member",
isOwner: user.isOwner || false

View File

@@ -45,6 +45,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation";
import { UserType } from "@server/types/UserTypes";
const UsersRolesFormSchema = z.object({
roles: z.array(
@@ -175,7 +176,7 @@ export default function ResourceAuthenticationPage() {
setAllUsers(
usersResponse.data.data.users.map((user) => ({
id: user.id.toString(),
text: user.email
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}))
);
@@ -183,7 +184,7 @@ export default function ResourceAuthenticationPage() {
"users",
resourceUsersResponse.data.data.users.map((i) => ({
id: i.userId.toString(),
text: i.email
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}))
);

View File

@@ -14,7 +14,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
export type GlobalUserRow = {
id: string;
email: string;
name: string | null;
username: string;
email: string | null;
type: string;
idpId: number | null;
idpName: string;
dateCreated: string;
};
@@ -67,6 +72,22 @@ export default function UsersTable({ users }: Props) {
);
}
},
{
accessorKey: "username",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Username
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "email",
header: ({ column }) => {
@@ -83,6 +104,38 @@ export default function UsersTable({ users }: Props) {
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Identity Provider
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
@@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {
<div className="space-y-4">
<p>
Are you sure you want to permanently delete{" "}
<b>{selected?.email || selected?.id}</b> from
the server?
<b>
{selected?.email ||
selected?.name ||
selected?.username}
</b>{" "}
from the server?
</p>
<p>
@@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {
</p>
<p>
To confirm, please type the email of the user
To confirm, please type the name of the user
below.
</p>
</div>
}
buttonText="Confirm Delete User"
onConfirm={async () => deleteUser(selected!.id)}
string={selected.email}
string={
selected.email || selected.name || selected.username
}
title="Delete User from Server"
/>
)}

View File

@@ -27,6 +27,11 @@ export default async function UsersPage(props: PageProps) {
return {
id: row.id,
email: row.email,
name: row.name,
username: row.username,
type: row.type,
idpId: row.idpId,
idpName: row.idpName || "Internal",
dateCreated: row.dateCreated,
serverAdmin: row.serverAdmin
};

View File

@@ -4,56 +4,58 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
type ValidateOidcTokenParams = {
orgId: string;
idpId: string;
code: string | undefined;
verifier: string | undefined;
storedState: string | undefined;
expectedState: string | undefined;
stateCookie: string | undefined;
};
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!props.code || !props.verifier) {
setError("Missing code or verifier");
setLoading(false);
return;
}
if (!props.storedState) {
setError("Missing stored state");
setLoading(false);
return;
}
if (props.storedState !== props.expectedState) {
setError("Invalid state");
setLoading(false);
return;
}
async function validate() {
setLoading(true);
console.log("Validating OIDC token", {
code: props.code,
expectedState: props.expectedState,
stateCookie: props.stateCookie
});
try {
const res = await api.post<
AxiosResponse<ValidateOidcUrlCallbackResponse>
>(
`/auth/org/${props.orgId}/idp/${props.idpId}/oidc/validate-callback`,
{
code: props.code,
codeVerifier: props.verifier
}
);
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
code: props.code,
state: props.expectedState,
storedState: props.stateCookie
});
console.log("Validate OIDC token response", res.data);
const redirectUrl = res.data.data.redirectUrl;
if (!redirectUrl) {
router.push("/");
}
if (redirectUrl.startsWith("http")) {
window.location.href = res.data.data.redirectUrl; // TODO: validate this to make sure it's safe
} else {
router.push(res.data.data.redirectUrl);
}
} catch (e) {
setError(formatAxiosError(e, "Error validating OIDC token"));
} finally {

View File

@@ -12,8 +12,7 @@ export default async function Page(props: {
const searchParams = await props.searchParams;
const allCookies = await cookies();
const stateCookie = allCookies.get("oidc_state")?.value;
const verifier = allCookies.get("oidc_code_verifier")?.value;
const stateCookie = allCookies.get("p_oidc_state")?.value;
return (
<>
@@ -21,9 +20,8 @@ export default async function Page(props: {
orgId={params.orgId}
idpId={params.idpId}
code={searchParams.code}
storedState={stateCookie}
expectedState={searchParams.state}
verifier={verifier}
stateCookie={stateCookie}
/>
</>
);

View File

@@ -36,7 +36,7 @@ export default async function Page(props: {
return (
<>
<VerifyEmailForm
email={user.email}
email={user.email!}
redirect={redirectUrl}
/>
</>

View File

@@ -35,7 +35,13 @@ export const orgNavItems: SidebarNavItem[] = [
children: [
{
title: "Users",
href: "/{orgId}/settings/access/users"
href: "/{orgId}/settings/access/users",
children: [
{
title: "Invitations",
href: "/{orgId}/settings/access/invitations"
}
]
},
{
title: "Roles",

View File

@@ -24,8 +24,8 @@ import {
import { Alert, AlertDescription } from "@/components/ui/alert";
import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";;
import { AxiosResponse, AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { LockIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -37,7 +37,8 @@ import {
} from "./ui/input-otp";
import Link from "next/link";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import Image from 'next/image'
import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
type LoginFormProps = {
redirect?: string;
@@ -130,60 +131,93 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
setLoading(false);
}
async function loginWithIdp(idpId: number) {
try {
const res = await api.post<AxiosResponse<GenerateOidcUrlResponse>>(
`/auth/idp/${idpId}/oidc/generate-url`,
{
redirectUrl: redirect || "/" // this is the post auth redirect url
}
);
console.log(res);
if (!res) {
setError("An error occurred while logging in");
return;
}
const data = res.data.data;
window.location.href = data.redirectUrl;
} catch (e) {
console.error(formatAxiosError(e));
}
}
return (
<div className="space-y-4">
{!mfaRequested && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="form"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="form"
>
<FormField
control={form.control}
name="password"
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="text-center">
<Link
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
Forgot your password?
</Link>
<div className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="text-center">
<Link
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
Forgot your password?
</Link>
</div>
</div>
</div>
</form>
</Form>
</form>
</Form>
<Button
type="button"
className="w-full"
onClick={() => {
loginWithIdp(1);
}}
>
OIDC Login
</Button>
</>
)}
{mfaRequested && (
@@ -193,7 +227,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
Two-Factor Authentication
</h3>
<p className="text-sm text-muted-foreground">
Enter the code from your authenticator app or one of your single-use backup codes.
Enter the code from your authenticator app or one of
your single-use backup codes.
</p>
</div>
<Form {...mfaForm}>

View File

@@ -38,7 +38,7 @@ export default function ProfileIcon() {
const [openDisable2fa, setOpenDisable2fa] = useState(false);
function getInitials() {
return user.email.substring(0, 1).toUpperCase();
return (user.email || user.name || user.username).substring(0, 1).toUpperCase();
}
function handleThemeChange(theme: "light" | "dark" | "system") {
@@ -68,7 +68,7 @@ export default function ProfileIcon() {
<div className="flex items-center md:gap-4 grow min-w-0 gap-2 md:gap-0">
<span className="truncate max-w-full font-medium min-w-0">
{user.email}
{user.email || user.name || user.username}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -92,7 +92,7 @@ export default function ProfileIcon() {
Signed in as
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
{user.email || user.name || user.username}
</p>
</div>
{user.serverAdmin && (