feat: oops commit the user role change page

This commit is contained in:
Aarnav Tale
2025-04-02 20:08:59 -04:00
parent 103a826178
commit 7d61ad50c4
6 changed files with 271 additions and 14 deletions
+13 -1
View File
@@ -4,15 +4,17 @@ import Menu from '~/components/Menu';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import Delete from '../dialogs/delete-user';
import Reassign from '../dialogs/reassign-user';
import Rename from '../dialogs/rename-user';
interface MenuProps {
user: User & {
headplaneRole: string;
machines: Machine[];
};
}
type Modal = 'rename' | 'delete' | null;
type Modal = 'rename' | 'delete' | 'reassign' | null;
export default function UserMenu({ user }: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
@@ -36,6 +38,15 @@ export default function UserMenu({ user }: MenuProps) {
}}
/>
)}
{modal === 'reassign' && (
<Reassign
user={user}
isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<Menu>
<Menu.IconButton
@@ -51,6 +62,7 @@ export default function UserMenu({ user }: MenuProps) {
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Rename user</Menu.Item>
<Menu.Item key="reassign">Change role</Menu.Item>
<Menu.Item key="delete" textValue="Delete">
<p className="text-red-500 dark:text-red-400">Delete</p>
</Menu.Item>
+1 -1
View File
@@ -58,7 +58,7 @@ export default function UserRow({ user, role }: UserRowProps) {
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions user={user} />
<MenuOptions user={{ ...user, headplaneRole: role }} />
</td>
</tr>
);
+101
View File
@@ -0,0 +1,101 @@
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import RadioGroup from '~/components/RadioGroup';
import { Roles } from '~/server/web/roles';
import { User } from '~/types';
interface ReassignProps {
user: User & { headplaneRole: string };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ReassignUser({
user,
isOpen,
setIsOpen,
}: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Change role for {user.name}?</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '}
<Link
to="https://tailscale.com/kb/1138/user-roles"
name="Tailscale User Roles documentation"
>
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === 'owner' ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input type="hidden" name="action_id" value="reassign_user" />
<input type="hidden" name="user_id" value={user.id} />
<RadioGroup
isRequired
name="new_role"
label="Role"
className="gap-4"
defaultValue={user.headplaneRole}
>
{Object.keys(Roles)
.filter((role) => role !== 'owner')
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
</div>
</RadioGroup.Radio>
);
})}
</RadioGroup>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
function mapRoleToName(role: string) {
switch (role) {
case 'admin':
return {
name: 'Admin',
desc: 'Can view the admin console, manage network, machine, and user settings.',
};
case 'network_admin':
return {
name: 'Network Admin',
desc: 'Can view the admin console and manage ACLs and network settings. Cannot manage machines or users.',
};
case 'it_admin':
return {
name: 'IT Admin',
desc: 'Can view the admin console and manage machines and users. Cannot manage ACLs or network settings.',
};
case 'auditor':
return {
name: 'Auditor',
desc: 'Can view the admin console.',
};
case 'member':
return {
name: 'Member',
desc: 'Cannot view the admin console.',
};
default:
return {
name: 'Unknown',
desc: 'Unknown',
};
}
}
+52 -10
View File
@@ -1,5 +1,8 @@
import { ActionFunctionArgs, data } from 'react-router';
import { ActionFunctionArgs, Session, data } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles';
import { AuthSession } from '~/server/web/sessions';
import { User } from '~/types';
export async function userAction({
request,
@@ -21,8 +24,8 @@ export async function userAction({
return deleteUser(formData, apiKey, context);
case 'rename_user':
return renameUser(formData, apiKey, context);
case 'change_owner':
return changeOwner(formData, apiKey, context);
case 'reassign_user':
return reassignUser(formData, apiKey, context, session);
default:
return data({ success: false }, 400);
}
@@ -75,18 +78,57 @@ async function renameUser(
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
async function changeOwner(
async function reassignUser(
formData: FormData,
apiKey: string,
context: LoadContext,
session: Session<AuthSession, unknown>,
) {
const userId = formData.get('user_id')?.toString();
const nodeId = formData.get('node_id')?.toString();
if (!userId || !nodeId) {
const executor = session.get('user');
if (!executor?.subject) {
return data({ success: false }, 400);
}
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
user: userId,
});
const check = await context.sessions.checkSubject(
executor.subject,
Capabilities.write_users,
);
if (!check) {
return data({ success: false }, 403);
}
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
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?.providerId) {
return data({ success: false }, 400);
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop();
if (!subject) {
return data({ success: false }, 400);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
return data({ success: false }, 403);
}
return data({ success: true });
}