fix: prevent user rename if they are an oidc user

This commit is contained in:
Aarnav Tale 2025-04-03 16:25:30 -04:00
parent 8d1132606a
commit 0ad578e651
No known key found for this signature in database
3 changed files with 47 additions and 16 deletions

View File

@ -17,6 +17,7 @@ import cn from '~/utils/cn';
interface MenuProps extends MenuTriggerProps { interface MenuProps extends MenuTriggerProps {
placement?: Placement; placement?: Placement;
isDisabled?: boolean; isDisabled?: boolean;
disabledKeys?: Key[];
children: [ children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>, React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>, React.ReactElement<MenuPanelProps>,
@ -26,7 +27,7 @@ interface MenuProps extends MenuTriggerProps {
// TODO: onAction is called twice for some reason? // TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop // TODO: isDisabled per-prop
function Menu(props: MenuProps) { function Menu(props: MenuProps) {
const { placement = 'bottom', isDisabled } = props; const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props); const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null); const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>( const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
@ -51,6 +52,7 @@ function Menu(props: MenuProps) {
...menuProps, ...menuProps,
autoFocus: state.focusStrategy ?? true, autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(), onClose: () => state.close(),
disabledKeys,
})} })}
</Popover> </Popover>
)} )}
@ -60,6 +62,7 @@ function Menu(props: MenuProps) {
interface MenuPanelProps extends AriaMenuProps<object> { interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void; onClose?: () => void;
disabledKeys?: Key[];
} }
function Panel(props: MenuPanelProps) { function Panel(props: MenuPanelProps) {
@ -74,7 +77,12 @@ function Panel(props: MenuPanelProps) {
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none" className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
> >
{[...state.collection].map((item) => ( {[...state.collection].map((item) => (
<MenuSection key={item.key} section={item} state={state} /> <MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))} ))}
</ul> </ul>
); );
@ -83,9 +91,10 @@ function Panel(props: MenuPanelProps) {
interface MenuSectionProps<T> { interface MenuSectionProps<T> {
section: Node<T>; section: Node<T>;
state: TreeState<T>; state: TreeState<T>;
disabledKeys?: Key[];
} }
function MenuSection<T>({ section, state }: MenuSectionProps<T>) { function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({ const { itemProps, groupProps } = useMenuSection({
heading: section.rendered, heading: section.rendered,
'aria-label': section['aria-label'], 'aria-label': section['aria-label'],
@ -109,7 +118,12 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
<li {...itemProps}> <li {...itemProps}>
<ul {...groupProps}> <ul {...groupProps}>
{[...section.childNodes].map((item) => ( {[...section.childNodes].map((item) => (
<MenuItem key={item.key} item={item} state={state} /> <MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))} ))}
</ul> </ul>
</li> </li>
@ -120,14 +134,14 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
interface MenuItemProps<T> { interface MenuItemProps<T> {
item: Node<T>; item: Node<T>;
state: TreeState<T>; state: TreeState<T>;
isDisabled?: boolean;
} }
function MenuItem<T>({ item, state }: MenuItemProps<T>) { function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null); const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref); const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key; const isFocused = state.selectionManager.focusedKey === item.key;
const isDisabled = state.selectionManager.isDisabled(item.key);
return ( return (
<li <li

View File

@ -48,7 +48,7 @@ export default function UserMenu({ user }: MenuProps) {
/> />
)} )}
<Menu> <Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : []}>
<Menu.IconButton <Menu.IconButton
label="Machine Options" label="Machine Options"
className={cn( className={cn(

View File

@ -3,6 +3,7 @@ import type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles'; import { Capabilities, Roles } from '~/server/web/roles';
import { AuthSession } from '~/server/web/sessions'; import { AuthSession } from '~/server/web/sessions';
import { User } from '~/types'; import { User } from '~/types';
import { data400, data403 } from '~/utils/res';
export async function userAction({ export async function userAction({
request, request,
@ -11,14 +12,14 @@ export async function userAction({
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_users); const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) { if (!check) {
return data({ success: false }, 403); throw data403('You do not have permission to update users');
} }
const apiKey = session.get('api_key')!; const apiKey = session.get('api_key')!;
const formData = await request.formData(); const formData = await request.formData();
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
return data({ success: false }, 400); throw data400('Missing `action_id` in the form data.');
} }
switch (action) { switch (action) {
@ -31,7 +32,7 @@ export async function userAction({
case 'reassign_user': case 'reassign_user':
return reassignUser(formData, apiKey, context, session); return reassignUser(formData, apiKey, context, session);
default: default:
return data({ success: false }, 400); throw data400('Invalid `action_id` provided.');
} }
} }
@ -45,7 +46,7 @@ async function createUser(
const email = formData.get('email')?.toString(); const email = formData.get('email')?.toString();
if (!name) { if (!name) {
return data({ success: false }, 400); throw data400('Missing `username` in the form data.');
} }
await context.client.post('v1/user', apiKey, { await context.client.post('v1/user', apiKey, {
@ -62,7 +63,7 @@ async function deleteUser(
) { ) {
const userId = formData.get('user_id')?.toString(); const userId = formData.get('user_id')?.toString();
if (!userId) { if (!userId) {
return data({ success: false }, 400); throw data400('Missing `user_id` in the form data.');
} }
await context.client.delete(`v1/user/${userId}`, apiKey); await context.client.delete(`v1/user/${userId}`, apiKey);
@ -79,6 +80,21 @@ async function renameUser(
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data400(`No user found with id: ${userId}`);
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data403('Users managed by OIDC cannot be renamed');
}
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey); await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
} }
@ -86,12 +102,11 @@ async function reassignUser(
formData: FormData, formData: FormData,
apiKey: string, apiKey: string,
context: LoadContext, context: LoadContext,
session: Session<AuthSession, unknown>,
) { ) {
const userId = formData.get('user_id')?.toString(); const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString(); const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) { if (!userId || !newRole) {
return data({ success: false }, 400); throw data400('Missing `user_id` or `new_role` in the form data.');
} }
const { users } = await context.client.get<{ users: User[] }>( const { users } = await context.client.get<{ users: User[] }>(
@ -101,14 +116,16 @@ async function reassignUser(
const user = users.find((user) => user.id === userId); const user = users.find((user) => user.id === userId);
if (!user?.providerId) { if (!user?.providerId) {
return data({ success: false }, 400); throw data400('Specified user is not an OIDC user');
} }
// For some reason, headscale makes providerID a url where the // For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out // last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop(); const subject = user.providerId?.split('/').pop();
if (!subject) { if (!subject) {
return data({ success: false }, 400); throw data400(
'Malformed `providerId` for the specified user. Cannot find subject.',
);
} }
const result = await context.sessions.reassignSubject( const result = await context.sessions.reassignSubject(