diff --git a/app/components/Menu.tsx b/app/components/Menu.tsx index 3df17dc..4dc13f7 100644 --- a/app/components/Menu.tsx +++ b/app/components/Menu.tsx @@ -16,6 +16,7 @@ import cn from '~/utils/cn'; interface MenuProps extends MenuTriggerProps { placement?: Placement; + isDisabled?: boolean; children: [ React.ReactElement | React.ReactElement, React.ReactElement, @@ -23,8 +24,9 @@ interface MenuProps extends MenuTriggerProps { } // TODO: onAction is called twice for some reason? +// TODO: isDisabled per-prop function Menu(props: MenuProps) { - const { placement = 'bottom' } = props; + const { placement = 'bottom', isDisabled } = props; const state = useMenuTriggerState(props); const ref = useRef(null); const { menuTriggerProps, menuProps } = useMenuTrigger( @@ -40,6 +42,7 @@ function Menu(props: MenuProps) {
{cloneElement(button, { ...menuTriggerProps, + isDisabled: isDisabled, ref, })} {state.isOpen && ( diff --git a/app/routes/machines/components/machine.tsx b/app/routes/machines/components/machine-row.tsx similarity index 98% rename from app/routes/machines/components/machine.tsx rename to app/routes/machines/components/machine-row.tsx index 9724201..5f900e9 100644 --- a/app/routes/machines/components/machine.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -18,6 +18,7 @@ interface Props { isAgent?: boolean; magic?: string; stats?: HostInfo; + isDisabled?: boolean; } export default function MachineRow({ @@ -27,6 +28,7 @@ export default function MachineRow({ isAgent, magic, stats, + isDisabled, }: Props) { const expired = machine.expiry === '0001-01-01 00:00:00' || @@ -191,6 +193,7 @@ export default function MachineRow({ routes={routes} users={users} magic={magic} + isDisabled={isDisabled} /> diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index cccabdb..01a7e71 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -16,6 +16,7 @@ interface MenuProps { users: User[]; magic?: string; isFullButton?: boolean; + isDisabled?: boolean; } type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null; @@ -26,6 +27,7 @@ export default function MachineMenu({ magic, users, isFullButton, + isDisabled, }: MenuProps) { const [modal, setModal] = useState(null); @@ -96,7 +98,7 @@ export default function MachineMenu({ /> )} - + {isFullButton ? ( diff --git a/app/routes/machines/dialogs/new.tsx b/app/routes/machines/dialogs/new.tsx index b574579..f245243 100644 --- a/app/routes/machines/dialogs/new.tsx +++ b/app/routes/machines/dialogs/new.tsx @@ -8,12 +8,13 @@ import Menu from '~/components/Menu'; import Select from '~/components/Select'; import type { User } from '~/types'; -export interface NewProps { +export interface NewMachineProps { server: string; users: User[]; + isDisabled?: boolean; } -export default function New(data: NewProps) { +export default function NewMachine(data: NewMachineProps) { const [pushDialog, setPushDialog] = useState(false); const [mkey, setMkey] = useState(''); const navigate = useNavigate(); @@ -25,11 +26,8 @@ export default function New(data: NewProps) { Register Machine Key The machine key is given when you run{' '} - - tailscale up --login-server= - {data.server} - {' '} - on your device. + tailscale up --login-server={data.server} on + your device. @@ -53,7 +51,7 @@ export default function New(data: NewProps) { - + Add Device { diff --git a/app/routes/machines/action.tsx b/app/routes/machines/machine-actions.ts similarity index 59% rename from app/routes/machines/action.tsx rename to app/routes/machines/machine-actions.ts index 1c38def..728bde8 100644 --- a/app/routes/machines/action.tsx +++ b/app/routes/machines/machine-actions.ts @@ -1,43 +1,65 @@ import type { ActionFunctionArgs } from 'react-router'; import type { LoadContext } from '~/server'; +import { Capabilities } from '~/server/web/roles'; +import { Machine } from '~/types'; import log from '~/utils/log'; -import { send } from '~/utils/res'; +import { data400, data403, data404, send } from '~/utils/res'; -// TODO: Turn this into the same thing as dns-actions like machine-actions!!! -export async function menuAction({ +// TODO: Clean this up like dns-actions and user-actions +export async function machineAction({ request, context, }: ActionFunctionArgs) { const session = await context.sessions.auth(request); - const data = await request.formData(); - if (!data.has('_method') || !data.has('id')) { - return send( - { message: 'No method or ID provided' }, - { - status: 400, - }, - ); + const check = await context.sessions.check( + request, + Capabilities.write_machines, + ); + + const apiKey = session.get('api_key')!; + const formData = await request.formData(); + + // TODO: Rename this to 'action_id' and 'node_id' + const action = formData.get('_method')?.toString(); + const nodeId = formData.get('id')?.toString(); + if (!action || !nodeId) { + return data400('Missing required parameters: _method and id'); } - const id = String(data.get('id')); - const method = String(data.get('_method')); + const { nodes } = await context.client.get<{ nodes: Machine[] }>( + 'v1/node', + apiKey, + ); - switch (method) { + const node = nodes.find((node) => node.id === nodeId); + if (!node) { + return data404(`Node with ID ${nodeId} not found`); + } + + const subject = session.get('user')!.subject; + if (node.user.providerId?.split('/').pop() !== subject) { + if (!check) { + return data403('You do not have permission to act on this machine'); + } + } + + // TODO: Split up into methods + switch (action) { case 'delete': { - await context.client.delete(`v1/node/${id}`, session.get('api_key')!); + await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!); return { message: 'Machine removed' }; } case 'expire': { await context.client.post( - `v1/node/${id}/expire`, + `v1/node/${nodeId}/expire`, session.get('api_key')!, ); return { message: 'Machine expired' }; } case 'rename': { - if (!data.has('name')) { + if (!formData.has('name')) { return send( { message: 'No name provided' }, { @@ -46,16 +68,16 @@ export async function menuAction({ ); } - const name = String(data.get('name')); + const name = String(formData.get('name')); await context.client.post( - `v1/node/${id}/rename/${name}`, + `v1/node/${nodeId}/rename/${name}`, session.get('api_key')!, ); return { message: 'Machine renamed' }; } case 'routes': { - if (!data.has('route') || !data.has('enabled')) { + if (!formData.has('route') || !formData.has('enabled')) { return send( { message: 'No route or enabled provided' }, { @@ -64,8 +86,8 @@ export async function menuAction({ ); } - const route = String(data.get('route')); - const enabled = data.get('enabled') === 'true'; + const route = String(formData.get('route')); + const enabled = formData.get('enabled') === 'true'; const postfix = enabled ? 'enable' : 'disable'; await context.client.post( @@ -76,7 +98,7 @@ export async function menuAction({ } case 'exit-node': { - if (!data.has('routes') || !data.has('enabled')) { + if (!formData.has('routes') || !formData.has('enabled')) { return send( { message: 'No route or enabled provided' }, { @@ -85,8 +107,8 @@ export async function menuAction({ ); } - const routes = data.get('routes')?.toString().split(',') ?? []; - const enabled = data.get('enabled') === 'true'; + const routes = formData.get('routes')?.toString().split(',') ?? []; + const enabled = formData.get('enabled') === 'true'; const postfix = enabled ? 'enable' : 'disable'; await Promise.all( @@ -102,7 +124,7 @@ export async function menuAction({ } case 'move': { - if (!data.has('to')) { + if (!formData.has('to')) { return send( { message: 'No destination provided' }, { @@ -111,22 +133,22 @@ export async function menuAction({ ); } - const to = String(data.get('to')); + const to = String(formData.get('to')); try { await context.client.post( - `v1/node/${id}/user`, + `v1/node/${nodeId}/user`, session.get('api_key')!, { user: to, }, ); - return { message: `Moved node ${id} to ${to}` }; + return { message: `Moved node ${nodeId} to ${to}` }; } catch (error) { console.error(error); return send( - { message: `Failed to move node ${id} to ${to}` }, + { message: `Failed to move node ${nodeId} to ${to}` }, { status: 500, }, @@ -136,7 +158,7 @@ export async function menuAction({ case 'tags': { const tags = - data + formData .get('tags') ?.toString() .split(',') @@ -144,7 +166,7 @@ export async function menuAction({ try { await context.client.post( - `v1/node/${id}/tags`, + `v1/node/${nodeId}/tags`, session.get('api_key')!, { tags, @@ -164,8 +186,8 @@ export async function menuAction({ } case 'register': { - const key = data.get('mkey')?.toString(); - const user = data.get('user')?.toString(); + const key = formData.get('mkey')?.toString(); + const user = formData.get('user')?.toString(); if (!key) { return send( diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 56b88c8..ab93056 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -12,9 +12,9 @@ import Tooltip from '~/components/Tooltip'; import type { LoadContext } from '~/server'; import type { Machine, Route, User } from '~/types'; import cn from '~/utils/cn'; -import { menuAction } from './action'; import MenuOptions from './components/menu'; import Routes from './dialogs/routes'; +import { machineAction } from './machine-actions'; export async function loader({ request, @@ -59,7 +59,7 @@ export async function loader({ } export async function action(request: ActionFunctionArgs) { - return menuAction(request); + return machineAction(request); } export default function Page() { diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index b6bbd24..750ce4e 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -6,17 +6,40 @@ import { ErrorPopup } from '~/components/Error'; import Link from '~/components/Link'; import Tooltip from '~/components/Tooltip'; import type { LoadContext } from '~/server'; +import { Capabilities } from '~/server/web/roles'; import type { Machine, Route, User } from '~/types'; import cn from '~/utils/cn'; -import { menuAction } from './action'; -import MachineRow from './components/machine'; +import MachineRow from './components/machine-row'; import NewMachine from './dialogs/new'; +import { machineAction } from './machine-actions'; export async function loader({ request, context, }: LoaderFunctionArgs) { const session = await context.sessions.auth(request); + const user = session.get('user'); + if (!user) { + throw new Error('Missing user session. Please log in again.'); + } + + const check = await context.sessions.check( + request, + Capabilities.read_machines, + ); + + if (!check) { + // Not authorized to view this page + throw new Error( + 'You do not have permission to view this page. Please contact your administrator.', + ); + } + + const writablePermission = await context.sessions.check( + request, + Capabilities.write_machines, + ); + const [machines, routes, users] = await Promise.all([ context.client.get<{ nodes: Machine[] }>( 'v1/node', @@ -45,11 +68,13 @@ export async function loader({ publicServer: context.config.headscale.public_url, agents: context.agents?.tailnetIDs(), stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)), + writable: writablePermission, + subject: user.subject, }; } export async function action(request: ActionFunctionArgs) { - return menuAction(request); + return machineAction(request); } export default function Page() { @@ -73,6 +98,7 @@ export default function Page() {
@@ -123,6 +149,11 @@ export default function Page() { // This is useful for when there are no agents configured isAgent={data.agents?.includes(machine.id)} stats={data.stats?.[machine.nodeKey]} + isDisabled={ + data.writable + ? false // If the user has write permissions, they can edit all machines + : machine.user.providerId?.split('/').pop() !== data.subject + } /> ))} diff --git a/app/utils/res.ts b/app/utils/res.ts index 1cdf80f..680c864 100644 --- a/app/utils/res.ts +++ b/app/utils/res.ts @@ -7,3 +7,30 @@ export function send(payload: T, init?: number | ResponseInit) { export function send401(payload: T) { return data(payload, { status: 401 }); } + +export function data400(message: string) { + return data( + { + success: false, + message, + }, + { status: 400 }, + ); +} + +export function data403(message: string) { + return data({ + success: false, + message, + }); +} + +export function data404(message: string) { + return data( + { + success: false, + message, + }, + { status: 404 }, + ); +}