+ {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 (
-
- );
-}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
-
- );
-}
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 (
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.