diff --git a/app/routes/users/components/auth.tsx b/app/routes/users/components/auth.tsx deleted file mode 100644 index 187e1a9..0000000 --- a/app/routes/users/components/auth.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react'; - -import Card from '~/components/Card'; -import Link from '~/components/Link'; - -import Add from '../dialogs/add'; - -interface Props { - readonly magic: string | undefined; -} - -export default function Auth({ magic }: Props) { - return ( - -
-
- -

Basic Authentication

-

- Users are not managed externally. Using OpenID Connect can create a - better experience when using Headscale.{' '} - - Learn more - -

-
-
- -

User Management

-

- You can add, remove, and rename users here. -

-
- -
-
-
-
- ); -} diff --git a/app/routes/users/components/manage-banner.tsx b/app/routes/users/components/manage-banner.tsx new file mode 100644 index 0000000..aab9f0e --- /dev/null +++ b/app/routes/users/components/manage-banner.tsx @@ -0,0 +1,69 @@ +import { Building2, House, Key } from 'lucide-react'; +import Card from '~/components/Card'; +import Link from '~/components/Link'; +import { HeadplaneConfig } from '~/utils/state'; +import CreateUser from '../dialogs/create-user'; + +interface Props { + oidc?: NonNullable; +} + +export default function ManageBanner({ oidc }: Props) { + return ( + +
+
+ {oidc ? ( + + ) : ( + + )} +

+ {oidc ? 'OpenID Connect' : 'User Authentication'} +

+

+ {oidc ? ( + <> + Users are managed through your{' '} + + OpenID Connect provider + + {'. '} + Groups and user information do not automatically sync.{' '} + + Learn more + + + ) : ( + <> + Users are not managed externally. Using OpenID Connect can + create a better experience when using Headscale.{' '} + + Learn more + + + )} +

+
+
+ +

User Management

+

+ {oidc + ? 'You can still add users manually, however it is recommended that you manage users through your OIDC provider.' + : 'You can add, remove, and rename users here.'} +

+
+ +
+
+
+
+ ); +} diff --git a/app/routes/users/components/oidc.tsx b/app/routes/users/components/oidc.tsx deleted file mode 100644 index 9b01159..0000000 --- a/app/routes/users/components/oidc.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'; -import Card from '~/components/Card'; -import Link from '~/components/Link'; -import { HeadplaneConfig } from '~/utils/state'; -import Add from '../dialogs/add'; - -interface Props { - readonly oidc: NonNullable; -} - -export default function Oidc({ oidc }: Props) { - return ( - -
-
- -

OpenID Connect

-

- Users are managed through your{' '} - - OpenID Connect provider - - {'. '} - Groups and user information do not automatically sync.{' '} - - Learn more - -

-
-
- -

User Management

-

- You can still add users manually, however it is recommended that you - manage users through your OIDC provider. -

-
- -
-
-
-
- ); -} diff --git a/app/routes/users/dialogs/add.tsx b/app/routes/users/dialogs/add.tsx deleted file mode 100644 index fe6348c..0000000 --- a/app/routes/users/dialogs/add.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Code from '~/components/Code'; -import Dialog from '~/components/Dialog'; -import Input from '~/components/Input'; - -export default function Add() { - return ( - - Add a new user - - Add a new user - - Enter a username to create a new user. Usernames can be addressed when - managing ACL policies. - - - - - - ); -} diff --git a/app/routes/users/dialogs/create-user.tsx b/app/routes/users/dialogs/create-user.tsx new file mode 100644 index 0000000..dd9819e --- /dev/null +++ b/app/routes/users/dialogs/create-user.tsx @@ -0,0 +1,33 @@ +import Dialog from '~/components/Dialog'; +import Input from '~/components/Input'; + +// TODO: Support image upload for user avatars +export default function CreateUser() { + return ( + + Add a new user + + Add a new user + + Enter a username to create a new user. Usernames can be addressed when + managing ACL policies. + + +
+ + + +
+
+
+ ); +} diff --git a/app/routes/users/dialogs/delete-user.tsx b/app/routes/users/dialogs/delete-user.tsx new file mode 100644 index 0000000..6905e2a --- /dev/null +++ b/app/routes/users/dialogs/delete-user.tsx @@ -0,0 +1,30 @@ +import { X } from 'lucide-react'; +import Dialog from '~/components/Dialog'; +import { User } from '~/types'; + +interface Props { + user: User; +} + +// TODO: Warn that OIDC users will be recreated on next login +export default function DeleteUser({ user }: Props) { + const name = + (user.displayName?.length ?? 0) > 0 ? user.displayName : user.name; + + return ( + + + + + + Delete {name}? + + Are you sure you want to delete {name}? A deleted user cannot be + recovered. + + + + + + ); +} diff --git a/app/routes/users/dialogs/remove.tsx b/app/routes/users/dialogs/remove.tsx deleted file mode 100644 index 742074d..0000000 --- a/app/routes/users/dialogs/remove.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { X } from 'lucide-react'; -import Code from '~/components/Code'; -import Dialog from '~/components/Dialog'; - -interface Props { - username: string; -} - -export default function Remove({ username }: Props) { - return ( - - - - - - Delete {username}? - - Are you sure you want to delete {username}? A deleted user cannot be - recovered. - - - - - - ); -} diff --git a/app/routes/users/dialogs/rename.tsx b/app/routes/users/dialogs/rename-user.tsx similarity index 50% rename from app/routes/users/dialogs/rename.tsx rename to app/routes/users/dialogs/rename-user.tsx index 48be347..b9489d4 100644 --- a/app/routes/users/dialogs/rename.tsx +++ b/app/routes/users/dialogs/rename-user.tsx @@ -1,35 +1,34 @@ import { Pencil } from 'lucide-react'; -import { useState } from 'react'; import Dialog from '~/components/Dialog'; import Input from '~/components/Input'; +import { User } from '~/types'; interface Props { - username: string; + user: User; } // TODO: Server side validation before submitting -export default function Rename({ username }: Props) { - const [newName, setNewName] = useState(username); - +export default function RenameUser({ user }: Props) { return ( - + - Rename {username}? - - Enter a new username for {username}. Changing a username will not + Rename {user.name}? + + Enter a new username for {user.name}. Changing a username will not update any ACL policies that may refer to this user by their old username. - - + + diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index b665ccb..0bd2f67 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -2,7 +2,7 @@ import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'; import { PersonIcon } from '@primer/octicons-react'; import { useEffect, useState } from 'react'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; -import { useActionData, useLoaderData, useSubmit } from 'react-router'; +import { useLoaderData, useSubmit } from 'react-router'; import { ClientOnly } from 'remix-utils/client-only'; import Attribute from '~/components/Attribute'; @@ -11,16 +11,14 @@ import { ErrorPopup } from '~/components/Error'; import StatusCircle from '~/components/StatusCircle'; import type { Machine, User } from '~/types'; import cn from '~/utils/cn'; -import { del, post, pull } from '~/utils/headscale'; -import { send } from '~/utils/res'; +import { pull } from '~/utils/headscale'; import { getSession } from '~/utils/sessions.server'; import { hp_getConfig, hs_getConfig } from '~/utils/state'; -import toast from '~/utils/toast'; -import Auth from './components/auth'; -import Oidc from './components/oidc'; -import Remove from './dialogs/remove'; -import Rename from './dialogs/rename'; +import ManageBanner from './components/manage-banner'; +import DeleteUser from './dialogs/delete-user'; +import RenameUser from './dialogs/rename-user'; +import { userAction } from './user-actions'; export async function loader({ request }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); @@ -52,94 +50,18 @@ export async function loader({ request }: LoaderFunctionArgs) { }; } -export async function action({ request }: ActionFunctionArgs) { - const session = await getSession(request.headers.get('Cookie')); - if (!session.has('hsApiKey')) { - return send({ message: 'Unauthorized' }, 401); - } - - const data = await request.formData(); - if (!data.has('_method')) { - return send({ message: 'No method provided' }, 400); - } - - const method = String(data.get('_method')); - - switch (method) { - case 'create': { - if (!data.has('username')) { - return send({ message: 'No name provided' }, 400); - } - - const username = String(data.get('username')); - await post('v1/user', session.get('hsApiKey')!, { - name: username, - }); - - return { message: `User ${username} created` }; - } - - case 'delete': { - if (!data.has('username')) { - return send({ message: 'No name provided' }, 400); - } - - const username = String(data.get('username')); - await del(`v1/user/${username}`, session.get('hsApiKey')!); - return { message: `User ${username} deleted` }; - } - - case 'rename': { - if (!data.has('old') || !data.has('new')) { - return send({ message: 'No old or new name provided' }, 400); - } - - const old = String(data.get('old')); - const newName = String(data.get('new')); - await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!); - return { message: `User ${old} renamed to ${newName}` }; - } - - case 'move': { - if (!data.has('id') || !data.has('to') || !data.has('name')) { - return send({ message: 'No ID or destination provided' }, 400); - } - - const id = String(data.get('id')); - const to = String(data.get('to')); - const name = String(data.get('name')); - - try { - await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!); - return { message: `Moved ${name} to ${to}` }; - } catch { - return send({ message: `Failed to move ${name} to ${to}` }, 500); - } - } - - default: { - return send({ message: 'Invalid method' }, 400); - } - } +export async function action(data: ActionFunctionArgs) { + return userAction(data); } export default function Page() { const data = useLoaderData(); - const [users, setUsers] = useState(data.users); - const actionData = useActionData(); - - useEffect(() => { - if (!actionData) { - return; - } - - toast(actionData.message); - if (actionData.message.startsWith('Failed')) { - setUsers(data.users); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionData]); + const [users, setUsers] = useState(data.users); + // This useEffect is entirely for the purpose of updating the users when the + // drag and drop changes the machines between users. It's pretty hacky, but + // the idea is to treat data.users as the source of truth and update the + // local state when it changes. useEffect(() => { setUsers(data.users); }, [data.users]); @@ -151,7 +73,7 @@ export default function Page() { Manage the users in your network and their permissions. Tip: You can drag machines between users to change ownership.

- {data.oidc ? : } + }> {() => ( {user.name}
- + {user.machines.length === 0 ? ( - + ) : undefined}
diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts new file mode 100644 index 0000000..93f51b1 --- /dev/null +++ b/app/routes/users/user-actions.ts @@ -0,0 +1,81 @@ +import { ActionFunctionArgs, data } from 'react-router'; +import { del, post } from '~/utils/headscale'; +import { auth } from '~/utils/sessions.server'; + +export async function userAction({ request }: ActionFunctionArgs) { + const session = await auth(request); + if (!session) { + return data({ success: false }, 401); + } + + const formData = await request.formData(); + const action = formData.get('action_id')?.toString(); + if (!action) { + return data({ success: false }, 400); + } + + const apiKey = session.get('hsApiKey'); + if (!apiKey) { + return data({ success: false }, 401); + } + + switch (action) { + case 'create_user': + return createUser(formData, apiKey); + case 'delete_user': + return deleteUser(formData, apiKey); + case 'rename_user': + return renameUser(formData, apiKey); + case 'change_owner': + return changeOwner(formData, apiKey); + default: + return data({ success: false }, 400); + } +} + +async function createUser(formData: FormData, apiKey: string) { + const name = formData.get('username')?.toString(); + const displayName = formData.get('display_name')?.toString(); + const email = formData.get('email')?.toString(); + + if (!name) { + return data({ success: false }, 400); + } + + await post('v1/user', apiKey, { + name, + displayName, + email, + }); +} + +async function deleteUser(formData: FormData, apiKey: string) { + const userId = formData.get('user_id')?.toString(); + if (!userId) { + return data({ success: false }, 400); + } + + await del(`v1/user/${userId}`, apiKey); +} + +async function renameUser(formData: FormData, apiKey: string) { + const userId = formData.get('user_id')?.toString(); + const newName = formData.get('new_name')?.toString(); + if (!userId || !newName) { + return data({ success: false }, 400); + } + + await post(`v1/user/${userId}/rename/${newName}`, apiKey); +} + +async function changeOwner(formData: FormData, apiKey: string) { + const userId = formData.get('user_id')?.toString(); + const nodeId = formData.get('node_id')?.toString(); + if (!userId || !nodeId) { + return data({ success: false }, 400); + } + + await post(`v1/node/${nodeId}/user`, apiKey, { + user: userId, + }); +} diff --git a/app/types/User.ts b/app/types/User.ts index c7cb7c4..06161fb 100644 --- a/app/types/User.ts +++ b/app/types/User.ts @@ -2,4 +2,9 @@ export interface User { id: string; name: string; createdAt: string; + displayName?: string; + email?: string; + providerId?: string; + provider?: string; + profilePicUrl?: string; } diff --git a/app/utils/sessions.server.ts b/app/utils/sessions.server.ts index 8c1350c..f7680c3 100644 --- a/app/utils/sessions.server.ts +++ b/app/utils/sessions.server.ts @@ -53,6 +53,7 @@ export function getSession(cookie: string | null) { return sessionStorage.getSession(cookie); } +export type ServerSession = Session; export async function auth(request: Request) { if (!sessionStorage) { return false; @@ -64,7 +65,7 @@ export async function auth(request: Request) { return false; } - return true; + return session; } export function destroySession(session: Session) {