feat: oops commit the user role change page
This commit is contained in:
parent
103a826178
commit
7d61ad50c4
67
app/components/RadioGroup.tsx
Normal file
67
app/components/RadioGroup.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { createContext, useContext, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
AriaRadioGroupProps,
|
||||||
|
AriaRadioProps,
|
||||||
|
VisuallyHidden,
|
||||||
|
useFocusRing,
|
||||||
|
} from 'react-aria';
|
||||||
|
import { RadioGroupState } from 'react-stately';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
|
import { useRadio, useRadioGroup } from 'react-aria';
|
||||||
|
import { useRadioGroupState } from 'react-stately';
|
||||||
|
|
||||||
|
interface RadioGroupProps extends AriaRadioGroupProps {
|
||||||
|
children: React.ReactElement<RadioProps>[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioContext = createContext<RadioGroupState | null>(null);
|
||||||
|
|
||||||
|
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||||
|
const state = useRadioGroupState(props);
|
||||||
|
const { radioGroupProps, labelProps } = useRadioGroup(props, state);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
|
||||||
|
<VisuallyHidden>
|
||||||
|
<span {...labelProps}>{label}</span>
|
||||||
|
</VisuallyHidden>
|
||||||
|
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RadioProps extends AriaRadioProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Radio({ children, className, ...props }: RadioProps) {
|
||||||
|
const state = useContext(RadioContext);
|
||||||
|
const ref = useRef(null);
|
||||||
|
const { inputProps, isSelected, isDisabled } = useRadio(props, state!, ref);
|
||||||
|
const { isFocusVisible, focusProps } = useFocusRing();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<VisuallyHidden>
|
||||||
|
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
|
||||||
|
</VisuallyHidden>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-5 h-5 aspect-square rounded-full p-1 border-2',
|
||||||
|
'border border-headplane-600 dark:border-headplane-300',
|
||||||
|
isFocusVisible ? 'ring-4' : '',
|
||||||
|
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||||
|
isSelected
|
||||||
|
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
|
||||||
|
: '',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Object.assign(RadioGroup, { Radio });
|
||||||
@ -4,15 +4,17 @@ import Menu from '~/components/Menu';
|
|||||||
import type { Machine, User } from '~/types';
|
import type { Machine, User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import Delete from '../dialogs/delete-user';
|
import Delete from '../dialogs/delete-user';
|
||||||
|
import Reassign from '../dialogs/reassign-user';
|
||||||
import Rename from '../dialogs/rename-user';
|
import Rename from '../dialogs/rename-user';
|
||||||
|
|
||||||
interface MenuProps {
|
interface MenuProps {
|
||||||
user: User & {
|
user: User & {
|
||||||
|
headplaneRole: string;
|
||||||
machines: Machine[];
|
machines: Machine[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type Modal = 'rename' | 'delete' | null;
|
type Modal = 'rename' | 'delete' | 'reassign' | null;
|
||||||
|
|
||||||
export default function UserMenu({ user }: MenuProps) {
|
export default function UserMenu({ user }: MenuProps) {
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
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>
|
||||||
<Menu.IconButton
|
<Menu.IconButton
|
||||||
@ -51,6 +62,7 @@ export default function UserMenu({ user }: MenuProps) {
|
|||||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||||
<Menu.Section>
|
<Menu.Section>
|
||||||
<Menu.Item key="rename">Rename user</Menu.Item>
|
<Menu.Item key="rename">Rename user</Menu.Item>
|
||||||
|
<Menu.Item key="reassign">Change role</Menu.Item>
|
||||||
<Menu.Item key="delete" textValue="Delete">
|
<Menu.Item key="delete" textValue="Delete">
|
||||||
<p className="text-red-500 dark:text-red-400">Delete</p>
|
<p className="text-red-500 dark:text-red-400">Delete</p>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export default function UserRow({ user, role }: UserRowProps) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-0.5">
|
<td className="py-2 pr-0.5">
|
||||||
<MenuOptions user={user} />
|
<MenuOptions user={{ ...user, headplaneRole: role }} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
101
app/routes/users/dialogs/reassign-user.tsx
Normal file
101
app/routes/users/dialogs/reassign-user.tsx
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import { ActionFunctionArgs, data } from 'react-router';
|
import { ActionFunctionArgs, Session, data } from 'react-router';
|
||||||
import type { LoadContext } from '~/server';
|
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({
|
export async function userAction({
|
||||||
request,
|
request,
|
||||||
@ -21,8 +24,8 @@ export async function userAction({
|
|||||||
return deleteUser(formData, apiKey, context);
|
return deleteUser(formData, apiKey, context);
|
||||||
case 'rename_user':
|
case 'rename_user':
|
||||||
return renameUser(formData, apiKey, context);
|
return renameUser(formData, apiKey, context);
|
||||||
case 'change_owner':
|
case 'reassign_user':
|
||||||
return changeOwner(formData, apiKey, context);
|
return reassignUser(formData, apiKey, context, session);
|
||||||
default:
|
default:
|
||||||
return data({ success: false }, 400);
|
return data({ success: false }, 400);
|
||||||
}
|
}
|
||||||
@ -75,18 +78,57 @@ async function renameUser(
|
|||||||
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
|
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeOwner(
|
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 executor = session.get('user');
|
||||||
const nodeId = formData.get('node_id')?.toString();
|
if (!executor?.subject) {
|
||||||
if (!userId || !nodeId) {
|
|
||||||
return data({ success: false }, 400);
|
return data({ success: false }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
|
const check = await context.sessions.checkSubject(
|
||||||
user: userId,
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,12 +85,12 @@ class Sessionizer {
|
|||||||
return session as Session<AuthSession, Error>;
|
return session as Session<AuthSession, Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
roleForSubject(subject: string) {
|
roleForSubject(subject: string): keyof typeof Roles | undefined {
|
||||||
const role = this.caps[subject];
|
const role = this.caps[subject];
|
||||||
// We need this in string form based on Object.keys of the roles
|
// We need this in string form based on Object.keys of the roles
|
||||||
for (const [key, value] of Object.entries(Roles)) {
|
for (const [key, value] of Object.entries(Roles)) {
|
||||||
if (value === role) {
|
if (value === role) {
|
||||||
return key;
|
return key as keyof typeof Roles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,6 +126,29 @@ class Sessionizer {
|
|||||||
return (capabilities & role) === capabilities;
|
return (capabilities & role) === capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkSubject(subject: string, capabilities: Capabilities) {
|
||||||
|
// This is the subject we set on API key based sessions. API keys
|
||||||
|
// inherently imply admin access so we return true for all checks.
|
||||||
|
if (subject === 'unknown-non-oauth') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the role does not exist, then this is a new subject that we have
|
||||||
|
// not seen before. Since this is new, we set access to the lowest
|
||||||
|
// level by default which is the member role.
|
||||||
|
//
|
||||||
|
// This also allows us to avoid configuring preventing sign ups with
|
||||||
|
// OIDC, since the default sign up logic gives member which does not
|
||||||
|
// have access to the UI whatsoever.
|
||||||
|
const role = this.caps[subject];
|
||||||
|
if (!role) {
|
||||||
|
const memberRole = await this.registerSubject(subject);
|
||||||
|
return (capabilities & memberRole) === capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (capabilities & role) === capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
// This code is very simple, if the user does not exist in the database
|
// This code is very simple, if the user does not exist in the database
|
||||||
// file then we register it with the lowest level of access. If the user
|
// file then we register it with the lowest level of access. If the user
|
||||||
// database is empty, the first user to sign in will be given the owner
|
// database is empty, the first user to sign in will be given the owner
|
||||||
@ -163,6 +186,18 @@ class Sessionizer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Updates the capabilities and roles of a subject
|
||||||
|
async reassignSubject(subject: string, role: keyof typeof Roles) {
|
||||||
|
// Check if we are owner
|
||||||
|
if (this.roleForSubject(subject) === 'owner') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.caps[subject] = Roles[role];
|
||||||
|
await this.flushUserDatabase();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
|
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
|
||||||
return this.storage.getSession(request.headers.get('cookie')) as Promise<
|
return this.storage.getSession(request.headers.get('cookie')) as Promise<
|
||||||
Session<T, Error>
|
Session<T, Error>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user