diff --git a/webapp/src/components/AdminPage.tsx b/webapp/src/components/AdminPage.tsx index 6307fce..7ce143e 100644 --- a/webapp/src/components/AdminPage.tsx +++ b/webapp/src/components/AdminPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'preact/hooks'; import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact'; import { copyTextToClipboard } from '@/lib/clipboard'; +import LoadingState from '@/components/LoadingState'; import type { AdminInvite, AdminUser } from '@/lib/types'; import { t } from '@/lib/i18n'; @@ -8,6 +9,8 @@ interface AdminPageProps { currentUserId: string; users: AdminUser[]; invites: AdminInvite[]; + loading: boolean; + error: string; onRefresh: () => void; onCreateInvite: (hours: number) => Promise; onDeleteAllInvites: () => Promise; @@ -48,8 +51,22 @@ export default function AdminPage(props: AdminPageProps) { return (
+ {!!props.error && ( +
+ {props.error} + +
+ )}
-

{t('txt_users')}

+
+

{t('txt_users')}

+ +
@@ -94,6 +111,20 @@ export default function AdminPage(props: AdminPageProps) { ); })} + {props.loading && !props.users.length && ( + + + + )} + {!props.loading && !props.users.length && ( + + + + )}
+ +
+
{t('txt_no_users_found')}
+
@@ -101,7 +132,7 @@ export default function AdminPage(props: AdminPageProps) {

{t('txt_invites')}

-
@@ -160,6 +191,20 @@ export default function AdminPage(props: AdminPageProps) { ))} + {props.loading && !props.invites.length && ( + + + + + + )} + {!props.loading && !props.invites.length && ( + + +
{t('txt_no_invites_found')}
+ + + )}
diff --git a/webapp/src/components/SecurityDevicesPage.tsx b/webapp/src/components/SecurityDevicesPage.tsx index 6ca1938..fd62fed 100644 --- a/webapp/src/components/SecurityDevicesPage.tsx +++ b/webapp/src/components/SecurityDevicesPage.tsx @@ -1,12 +1,14 @@ import { useState } from 'preact/hooks'; import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; import ConfirmDialog from '@/components/ConfirmDialog'; +import LoadingState from '@/components/LoadingState'; import type { AuthorizedDevice } from '@/lib/types'; import { t } from '@/lib/i18n'; interface SecurityDevicesPageProps { devices: AuthorizedDevice[]; loading: boolean; + error: string; onRefresh: () => void; onRenameDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeTrust: (device: AuthorizedDevice) => void; @@ -72,7 +74,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
- @@ -90,6 +92,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {

{t('txt_authorized_devices')}

+ {!!props.error && ( +
+ {props.error} + +
+ )} @@ -166,6 +177,13 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { ))} + {props.loading && props.devices.length === 0 && ( + + + + )} {!props.loading && props.devices.length === 0 && (
+ +
diff --git a/webapp/src/hooks/useAdminActions.ts b/webapp/src/hooks/useAdminActions.ts index 970a633..6f59c66 100644 --- a/webapp/src/hooks/useAdminActions.ts +++ b/webapp/src/hooks/useAdminActions.ts @@ -20,26 +20,39 @@ export default function useAdminActions(options: UseAdminActionsOptions) { return useMemo( () => ({ refreshAdmin() { - void refetchUsers(); - void refetchInvites(); + void Promise.all([refetchUsers(), refetchInvites()]).catch((error) => { + onNotify('error', error instanceof Error ? error.message : t('txt_load_admin_data_failed')); + }); }, async createInvite(hours: number) { - await createInvite(authedFetch, hours); - await refetchInvites(); - onNotify('success', t('txt_invite_created')); + try { + await createInvite(authedFetch, hours); + await refetchInvites(); + onNotify('success', t('txt_invite_created')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed')); + } }, async toggleUserStatus(userId: string, status: 'active' | 'banned') { - await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); - await refetchUsers(); - onNotify('success', t('txt_user_status_updated')); + try { + await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); + await refetchUsers(); + onNotify('success', t('txt_user_status_updated')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed')); + } }, async revokeInvite(code: string) { - await revokeInvite(authedFetch, code); - await refetchInvites(); - onNotify('success', t('txt_invite_revoked')); + try { + await revokeInvite(authedFetch, code); + await refetchInvites(); + onNotify('success', t('txt_invite_revoked')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed')); + } }, async deleteAllInvites() { @@ -50,9 +63,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) { onConfirm: () => { onSetConfirm(null); void (async () => { - await deleteAllInvites(authedFetch); - await refetchInvites(); - onNotify('success', t('txt_all_invites_deleted')); + try { + await deleteAllInvites(authedFetch); + await refetchInvites(); + onNotify('success', t('txt_all_invites_deleted')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_invites_failed')); + } })(); }, }); @@ -66,9 +83,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) { onConfirm: () => { onSetConfirm(null); void (async () => { - await deleteUser(authedFetch, userId); - await refetchUsers(); - onNotify('success', t('txt_user_deleted')); + try { + await deleteUser(authedFetch, userId); + await refetchUsers(); + onNotify('success', t('txt_user_deleted')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_delete_user_failed')); + } })(); }, }); diff --git a/webapp/src/styles/management.css b/webapp/src/styles/management.css index 1dc3e91..bb1bf64 100644 --- a/webapp/src/styles/management.css +++ b/webapp/src/styles/management.css @@ -698,7 +698,7 @@ } .local-error { - @apply mt-2.5 font-semibold; + @apply mt-2.5 flex flex-wrap items-center gap-2 font-semibold; color: #b42318; }