diff --git a/README.md b/README.md index 5f8e8fd..1ada0c0 100644 --- a/README.md +++ b/README.md @@ -59,16 +59,17 @@ to Better Auth's `genericOAuth` plugin. ## Scripts ```sh -bun run dev # vite dev server -bun run build # production build (adapter-node -> build/) -bun run preview # preview the production build -bun run check # svelte-check -bun run lint # prettier + eslint -bun run format # prettier --write -bun run db:push # apply schema to the DB -bun run db:generate # generate migration from schema changes -bun run db:migrate # run pending migrations -bun run db:studio # drizzle-kit studio +bun run dev # vite dev server +bun run type:generate # generate typed client for nadir-agent +bun run build # production build (adapter-node -> build/) +bun run preview # preview the production build +bun run check # svelte-check +bun run lint # prettier + eslint +bun run format # prettier --write +bun run db:push # apply schema to the DB +bun run db:generate # generate migration from schema changes +bun run db:migrate # run pending migrations +bun run db:studio # drizzle-kit studio ``` --- diff --git a/bun.lock b/bun.lock index d1cd2ce..9c807d3 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "ogl": "^1.0.11", "openapi-fetch": "^0.17.0", "runed": "^0.37.1", + "swapy": "^1.0.5", "uqr": "^0.1.3", "valibot": "^1.4.1", }, @@ -1111,6 +1112,8 @@ "sveltekit-superforms": ["sveltekit-superforms@2.30.1", "", { "dependencies": { "devalue": "^5.6.4", "memoize-weak": "^1.0.2", "ts-deepmerge": "^7.0.3" }, "optionalDependencies": { "@exodus/schemasafe": "^1.3.0", "@standard-schema/spec": "^1.1.0", "@typeschema/class-validator": "^0.3.0", "@valibot/to-json-schema": "^1.6.0", "@vinejs/vine": "^3.0.1", "arktype": "^2.2.0", "class-validator": "^0.14.4", "effect": "^3.21.0", "joi": "^17.13.3", "json-schema-to-ts": "^3.1.1", "superstruct": "^2.0.2", "typebox": "^1.1.6", "valibot": "^1.3.1", "yup": "^1.7.1", "zod": "^4.3.6", "zod-v3-to-json-schema": "^4.0.0" }, "peerDependencies": { "@sveltejs/kit": "1.x || 2.x", "svelte": "3.x || 4.x || >=5.0.0-next.51" } }, "sha512-wBzyqsE0idvEJWuNJ+HCiAtdxa7Z55GZ8jmtlVHJfonrk9bRYC49MoPaloYyFoYuU3QPy6Omna/Qzn1kaIkgew=="], + "swapy": ["swapy@1.0.5", "", {}, "sha512-XEzy5HCw7yESb7QajKTBJ+NA/BL0kAKlE7XhSMPZawF740X4ws/5CFtXRsVDQ+EztISC759a9+DJM9mxjz4hXg=="], + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], diff --git a/db.sqlite b/db.sqlite index b28cb84..e363219 100644 Binary files a/db.sqlite and b/db.sqlite differ diff --git a/messages/en.json b/messages/en.json index 2ad012b..8d9a72d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,25 +1,17 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "account": "Account", + "agent_update_failed": "Agent update did not complete - check `nadir logs` on the host", + "agent_update_started": "Updating agent... ({from} → {to})", + "agent_update_success": "Agent updated to {version}", + "agent_updates": "Agent outdated", "already_have_account": "Already have an account?", "appname": "NadiЯ", - "agent_updates":"Agent outdated", - "agent_update_started":"Updating agent... ({from} → {to})", - "agent_update_success":"Agent updated to {version}", - "agent_update_failed":"Agent update did not complete - check `nadir logs` on the host", - "machine_offline_title":"{name} is offline", - "machine_offline_description":"Could not reach the nadir-agent at {address}. The host may be down, the agent stopped, or the address is wrong.", - "machine_offline_code":"Error 502", - "machine_offline_node_you":"You", - "machine_offline_node_proxy":"Web UI", - "machine_offline_node_dest":"Agent", - "machine_offline_status_connected":"CONNECTED", - "machine_offline_status_unreachable":"UNREACHABLE", - "machine_offline_details":"Show error details", "back_to_login": "Back to login", "backup_code": "Backup code", "backup_codes_notice": "Store these somewhere safe. Each code works once if you lose your device.", "backup_codes_title": "Save your backup codes", + "cancel": "Cancel", "check_your_email": "Check your email", "code": "Code", "confirm_password": "Confirm password", @@ -69,14 +61,14 @@ "dashboard_network": "Network", "dashboard_next": "Next", "dashboard_none": "none", - "dashboard_nothing_to_show": "Nothing to show.", "dashboard_not_synced": "Not synced", + "dashboard_nothing_to_show": "Nothing to show.", "dashboard_os": "OS", "dashboard_packages": "Packages", "dashboard_pagination_info": "{start}–{end} of {total}", + "dashboard_pause": "Pause auto-refresh", "dashboard_prev": "Prev", "dashboard_recent_activity": "Recent activity", - "dashboard_pause": "Pause auto-refresh", "dashboard_refresh": "Refresh", "dashboard_resume": "Resume auto-refresh", "dashboard_search_placeholder": "Search", @@ -97,7 +89,9 @@ "dashboard_uptime": "Uptime", "dashboard_used_percent": "{used}% used", "dashboard_utc": "UTC", + "delete": "Delete", "download": "Download", + "edit": "Edit", "email": "Email", "email_complete_body": "Your account has been created. Choose a password to finish setting it up.", "email_complete_button": "Set password", @@ -121,29 +115,86 @@ "email_verify_ignore": "If you didn't create an account, you can safely ignore this email.", "email_verify_subject": "Verify your email address", "enter_password_to_continue": "Confirm your password to continue", + "errors_address_invalid": "Enter a valid URL, e.g. http://127.0.0.1:9999", "errors_email_invalid": "Enter a valid email address", "errors_generic": "An error occurred during this operation, please review Nadir Logs for more information.", "errors_invalid_code": "Invalid or expired code, try again.", - "errors_address_invalid": "Enter a valid URL, e.g. http://127.0.0.1:9999", "errors_non_empty": "This field is required", + "errors_not_found": "This item has not been found", "errors_password_too_short": "Password must be at least {min} characters", "errors_password_weak": "Use upper- and lower-case letters and at least one number.", "errors_passwords_no_match": "Passwords do not match", + "errors_unauthenticated": "Unauthenticated", "errors_username_too_short": "Username must be at least {min} characters", "errors_wrong_credentials": "Wrong credentials, try again.", - "errors_unauthenticated": "Unauthenticated", - "errors_not_found": "This item has not been found", "finish": "Finish", "forbidden": "You are not allowed to this operation.", "forgot_password": "Forgot your password?", "forgot_password_description": "Enter your email and we'll send you a reset link.", "forgot_password_title": "Forgot your password?", + "groups_add": "Add group", + "groups_add_member": "Add member", + "groups_add_member_action": "add", + "groups_add_member_description": "Adds this group to the user's supplementary set.", + "groups_add_member_no_results": "No matching users.", + "groups_add_member_search_placeholder": "Search user…", + "groups_add_member_title": "Add member to {name}", + "groups_create_description": "Adds a Unix group via groupadd.", + "groups_create_field_system": "System group", + "groups_create_title": "Create group", + "groups_created": "Group created", + "groups_delete_description": "Runs groupdel. Fails if it is the primary group of any existing user.", + "groups_delete_title": "Delete {name}?", + "groups_deleted": "Group deleted", + "groups_filter_show_system": "Show system groups", + "groups_filter_system_gid_hint": "(gid < 1000)", + "groups_member_added": "Added {username}", + "groups_member_removed": "Removed {username}", + "groups_members_description": "Supplementary members are managed via usermod -G on each user. Primary-group members (users whose primary gid is {gid}) appear below but cannot be removed from here.", + "groups_members_title": "Members", + "groups_nav_description": "Unix groups from /etc/group on this machine.", + "groups_nav_title": "Groups", + "groups_no_results": "No groups found.", + "groups_no_supplementary_members": "No supplementary members.", + "groups_not_found": "Group not found: {name}", + "groups_primary_empty": "None.", + "groups_primary_label": "Primary ({count})", + "groups_search_placeholder": "Search name or gid…", + "groups_supplementary_label": "Supplementary ({count})", "home": "Home", "invalid_reset_link": "This link is invalid or has expired.", + "language": "Language", "login": "Login", "login_social_description": "You have to login to use this platform. Use your favorite social or your credentials", "login_with": "Login with {social}", "logout": "Logout", + "machine_actions": "Server actions", + "machine_add": "Add server", + "machine_add_description": "Connect a new server to manage from this dashboard.", + "machine_address": "Address", + "machine_address_placeholder": "http://127.0.0.1:9999", + "machine_delete_confirm": "This permanently removes \"{name}\" from the database. The connected server is not touched.", + "machine_delete_title": "Delete server?", + "machine_edit": "Edit server", + "machine_edit_description": "Update the connection details for this server.", + "machine_name": "Name", + "machine_name_placeholder": "Production server", + "machine_none": "No servers yet.", + "machine_offline_code": "Error 502", + "machine_offline_description": "Could not reach the nadir-agent at {address}. The host may be down, the agent stopped, or the address is wrong.", + "machine_offline_details": "Show error details", + "machine_offline_node_dest": "Agent", + "machine_offline_node_proxy": "Web UI", + "machine_offline_node_you": "You", + "machine_offline_status_connected": "CONNECTED", + "machine_offline_status_unreachable": "UNREACHABLE", + "machine_offline_title": "{name} is offline", + "machine_save": "Add server", + "machine_save_edit": "Save changes", + "machine_search_placeholder": "Search servers…", + "machine_token": "Token", + "machine_token_keep": "Leave blank to keep the current token.", + "machine_token_placeholder": "Agent bearer token", "manual_entry_key": "Can't scan? Enter this key in your authenticator app manually:", "name": "Name", "name_placeholder": "Jane Doe", @@ -152,40 +203,27 @@ "nav_admin_config_desc": "Application-wide configuration.", "nav_admin_users": "Users", "nav_admin_users_desc": "Manage user accounts, roles and access.", - "cancel": "Cancel", - "delete": "Delete", - "edit": "Edit", - "machine_actions": "Server actions", - "machine_add": "Add server", - "machine_add_description": "Connect a new server to manage from this dashboard.", - "machine_delete_confirm": "This permanently removes \"{name}\" from the database. The connected server is not touched.", - "machine_delete_title": "Delete server?", - "machine_edit": "Edit server", - "machine_edit_description": "Update the connection details for this server.", - "machine_save_edit": "Save changes", - "machine_token_keep": "Leave blank to keep the current token.", - "machine_address": "Address", - "machine_address_placeholder": "http://127.0.0.1:9999", - "machine_name": "Name", - "machine_name_placeholder": "Production server", - "machine_none": "No servers yet.", - "machine_save": "Add server", - "machine_search_placeholder": "Search servers…", - "machine_token": "Token", - "machine_token_placeholder": "Agent bearer token", "nav_dashboard_overview": "Overview", "nav_dashboard_overview_desc": "System status at a glance.", - "pagination_next": "Next", - "pagination_page_of": "Page {page} of {pages}", - "pagination_previous": "Previous", "nav_system": "System", "nav_system_datetime": "Date & Time", "nav_system_datetime_desc": "Clock, timezone and time synchronisation.", + "nav_system_hostname": "Hostname", + "nav_system_hostname_desc": "Identify this machine on the network.", "nav_system_localization": "Localization", "nav_system_localization_desc": "Language, locale and region settings.", + "nav_system_power": "Power", + "nav_system_power_desc": "Reboot or power off the machine.", + "nav_users_groups": "Groups", + "nav_users_groups_desc": "Unix groups from /etc/group.", + "nav_users_system_users": "System users", + "nav_users_system_users_desc": "PAM/Unix accounts on this machine.", "new_password": "New password", "no_account": "No account yet?", "or": "Or", + "pagination_next": "Next", + "pagination_page_of": "Page {page} of {pages}", + "pagination_previous": "Previous", "password": "Password", "password_hint": "At least 8 characters, mixing upper- and lower-case letters and a number.", "privacy_policy": "Privacy Policy", @@ -194,14 +232,56 @@ "reset_password_action": "Update password", "reset_password_description": "Choose a strong password you don't use anywhere else.", "reset_password_title": "Set a new password", + "save": "Save", + "saved": "Saved", "scan_qr": "Add this key to your authenticator app, then enter the generated code below.", "send_reset_link": "Send reset link", + "settings": "Settings", "setup_2fa_description": "Add an extra layer of security to your account.", "setup_2fa_title": "Set up two-factor authentication", "sign_up": "Sign up", "sign_up_description": "Sign up with your email and a username.", "sign_up_title": "Create your account", + "system_hostname_current": "Current hostname", + "system_hostname_invalid": "Hostname is invalid", + "system_locale_generate": "Generate new locale", + "system_locale_generate_button": "Generate", + "system_locale_generate_desc": "Install a new locale on the host. On Debian/Ubuntu/Arch this uncomments the entry in /etc/locale.gen and runs locale-gen; on RHEL/Fedora it uses localedef.", + "system_locale_generate_invalid": "Use the form xx_XX.UTF-8 (e.g. fr_FR.UTF-8)", + "system_locale_generate_placeholder": "e.g. ja_JP.UTF-8", + "system_locale_keymap": "Console keymap", + "system_locale_lang": "System locale (LANG)", + "system_locale_language": "Fallback language (LANGUAGE)", + "system_locale_language_add": "Add language", + "system_locale_language_button": "Save", + "system_locale_language_desc": "Set the fallback language priority list for system messages and translations (optional).", + "system_locale_language_empty": "No fallback language set.", + "system_locale_language_placeholder": "e.g. en_US:en", + "system_locale_no_keymap_found": "No keymap found.", + "system_locale_no_locale_found": "No locale found.", + "system_locale_search_keymap_placeholder": "Search keymap…", + "system_locale_search_locale_placeholder": "Search locale…", + "system_locale_x11": "X11 layout", + "system_power_confirm_description": "The machine will be unreachable while it shuts down. This cannot be undone from here.", + "system_power_confirm_poweroff_title": "Power off this machine?", + "system_power_confirm_reboot_title": "Reboot this machine?", + "system_power_poweroff": "Power off", + "system_power_reboot": "Reboot", + "system_time_current": "Current time", + "system_time_manual": "Manual time", + "system_time_manual_hint": "Set the system clock to a specific RFC3339 time. Available only when NTP is off.", + "system_time_no_timezone_found": "No timezone found.", + "system_time_ntp": "Network time (NTP)", + "system_time_ntp_hint": "Automatically synchronize the clock with NTP servers.", + "system_time_ntp_not_synced": "Not synchronized", + "system_time_ntp_synced": "Synchronized", + "system_time_search_timezone_placeholder": "Search timezone…", + "system_time_timezone": "Timezone", "terms_notice": "By clicking continue, you agree to our Terms of Service and Privacy Policy.", + "theme": "Theme", + "theme_dark": "Dark", + "theme_light": "Light", + "theme_system": "System", "trust_device": "Trust this device for 30 days", "two_factor_description": "Enter the 6-digit code from your authenticator app.", "two_factor_title": "Two-factor authentication", @@ -209,76 +289,100 @@ "use_backup_code": "Use a backup code", "username": "Username", "username_placeholder": "admin", - "verification_sent": "We sent a verification link to {email}. Click it to activate your account.", - "verify": "Verify", - "verify_your_email": "You need to first verify your email address", - "welcome_back": "Welcome back", - "users_title": "Users", - "users_description": "Manage application users.", - "users_add": "Add User", - "users_create": "Create", - "users_edit": "Edit", - "users_delete": "Delete", - "users_ban": "Ban", - "users_unban": "Unban", - "users_search_placeholder": "Search by email…", - "users_rows_per_page": "Rows per page", - "users_role": "Role", - "users_role_user": "User", - "users_role_admin": "Admin", - "users_created_at": "Joined", - "users_status": "Status", - "users_banned": "Banned", - "users_active": "Active", + "users_action_set_password": "Set password", "users_actions": "Actions", - "users_create_title": "Create user", - "users_create_description": "Add a new user to the system.", - "users_edit_title": "Edit user", - "users_edit_description": "Update user details.", - "users_delete_confirm_title": "Delete user?", - "users_delete_confirm_description": "This permanently removes the user. Optionally ban the email to prevent re-registration.", - "users_delete_ban_email": "Also ban this email", - "users_ban_reason": "Reason (optional)", - "users_no_results": "No users found.", - "users_page_of": "Page {page} of {total}", - "users_prev": "Previous", - "users_next": "Next", - "users_saved": "User saved", - "users_created": "User created", - "users_deleted": "User deleted", + "users_active": "Active", + "users_add": "Add User", + "users_ban": "Ban", "users_ban_action_title": "Ban user?", - "cancel": "Cancel", + "users_ban_reason": "Reason (optional)", + "users_banned": "Banned", + "users_col_comment": "Comment", + "users_col_home": "Home", + "users_col_type": "Type", + "users_create": "Create", + "users_create_description": "Add a new user to the system.", + "users_create_field_comment": "Comment (GECOS)", + "users_create_field_create_home": "Create home directory", + "users_create_field_shell": "Shell", + "users_create_field_system": "System account", + "users_create_title": "Create user", + "users_created": "User created", + "users_created_at": "Joined", + "users_delete": "Delete", + "users_delete_ban_email": "Also ban this email", + "users_delete_confirm_description": "This permanently removes the user. Optionally ban the email to prevent re-registration.", + "users_delete_confirm_title": "Delete user?", + "users_delete_description": "Runs userdel on the host. This cannot be undone.", + "users_delete_field_remove_home": "Also remove home directory and mail spool", + "users_delete_title": "Delete {username}?", + "users_deleted": "User deleted", + "users_description": "Manage application users.", + "users_details": "Details", + "users_edit": "Edit", + "users_edit_description": "Update user details.", + "users_edit_title": "Edit user", "users_filter": "Filter", - "users_filter_title": "Filter Users", + "users_filter_24h": "Last 24h", + "users_filter_30d": "Last 30 days", + "users_filter_7d": "Last 7 days", "users_filter_active": "Active", "users_filter_active_hint": "Users with a recent session.", - "users_filter_joined": "Joined", "users_filter_any_time": "Any Time", - "users_filter_24h": "Last 24h", - "users_filter_7d": "Last 7 days", - "users_filter_30d": "Last 30 days", - "users_filter_date_range": "Date Range", - "users_filter_date_from": "From", - "users_filter_date_to": "To", - "users_filter_email_verified": "Email verified only", - "users_filter_online_only": "Online users only", - "users_filter_online_hint": "Users with an active session.", - "users_filter_show_banned": "Show banned users", - "users_filter_display": "Display", - "users_filter_reset": "Reset All", "users_filter_count": "{n} filters active", + "users_filter_date_from": "From", + "users_filter_date_range": "Date Range", + "users_filter_date_to": "To", + "users_filter_display": "Display", + "users_filter_email_verified": "Email verified only", + "users_filter_joined": "Joined", + "users_filter_online_hint": "Users with an active session.", + "users_filter_online_only": "Online users only", + "users_filter_reset": "Reset All", + "users_filter_shell_only": "Login-capable shell only", + "users_filter_show_banned": "Show banned users", + "users_filter_show_system": "Show system users", + "users_filter_system_uid_hint": "(uid < 1000)", + "users_filter_title": "Filter Users", + "users_group_primary_badge": "(primary)", + "users_group_sys_badge": "sys", + "users_groups_title": "Groups", + "users_pam_groups_description": "Supplementary groups. Replaces the full set via usermod -G. Primary group is set at user creation and not editable here.", + "users_groups_updated": "Groups updated", "users_invite": "Invite", - "users_invite_title": "Invite user", "users_invite_description": "Send an email invitation. The user sets their own password.", + "users_invite_title": "Invite user", "users_invited": "Invitation sent", + "users_nav_description": "PAM/Unix accounts from /etc/passwd on this machine.", + "users_nav_title": "System users", + "users_next": "Next", + "users_no_gecos": "No GECOS comment", + "users_no_groups": "No groups.", + "users_no_results": "No users found.", + "users_page_of": "Page {page} of {total}", + "users_pam_create_description": "Adds a PAM account via useradd. Password stays locked until you set one.", + "users_pam_search_placeholder": "Search username, GECOS, uid…", "users_pending": "Pending", "users_pending_expires": "Invite expires {date}", "users_pending_no_invite": "Email not verified", + "users_prev": "Previous", + "users_primary_gid": "Primary GID", "users_resend_invite": "Resend invite", - "settings": "Settings", - "theme": "Theme", - "theme_light": "Light", - "theme_dark": "Dark", - "theme_system": "System", - "language": "Language" + "users_role": "Role", + "users_role_admin": "Admin", + "users_role_user": "User", + "users_rows_per_page": "Rows per page", + "users_saved": "User saved", + "users_search_placeholder": "Search by email…", + "users_set_password_description": "Piped to chpasswd over stdin; never appears in the process list.", + "users_set_password_title": "Set password — {username}", + "users_status": "Status", + "users_title": "Users", + "users_type_system": "system", + "users_type_user": "user", + "users_unban": "Unban", + "verification_sent": "We sent a verification link to {email}. Click it to activate your account.", + "verify": "Verify", + "verify_your_email": "You need to first verify your email address", + "welcome_back": "Welcome back" } diff --git a/package.json b/package.json index 2c2b5ab..2cfcbca 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", "format": "prettier --write .", + "type:generate": "bunx openapi-typescript http://100.64.0.189:9999/openapi.json -o ./src/lib/server/nadir-agent/schema.d.ts", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", @@ -74,6 +75,7 @@ "ogl": "^1.0.11", "openapi-fetch": "^0.17.0", "runed": "^0.37.1", + "swapy": "^1.0.5", "uqr": "^0.1.3", "valibot": "^1.4.1" } diff --git a/src/lib/components/blocks/breadcrumbs/breadcrumbs.svelte b/src/lib/components/blocks/breadcrumbs/breadcrumbs.svelte index bb4db41..4763e6d 100644 --- a/src/lib/components/blocks/breadcrumbs/breadcrumbs.svelte +++ b/src/lib/components/blocks/breadcrumbs/breadcrumbs.svelte @@ -5,16 +5,22 @@ import { page } from '$app/state'; import * as Breadcrumb from '$lib/components/ui/breadcrumb'; import { m } from '$lib/paraglide/messages'; + import { getMachine } from '$lib/remotes/machines.remote'; + + const machine = $derived(page.params.machineId ? getMachine(page.params.machineId) : null); const LABELS: Record string> = { '/': m.home, - '/admin': m.nav_admin, - '/admin/config': m.nav_admin_config, - '/admin/users': m.nav_admin_users, - '/dashboard': m.dashboard, - '/system': m.nav_system, - '/system/date-time': m.nav_system_datetime, - '/system/localization': m.nav_system_localization + admin: m.nav_admin, + config: m.nav_admin_config, + dashboard: m.dashboard, + 'date-time': m.nav_system_datetime, + groups: () => 'Groups', + hostname: m.nav_system_hostname, + localization: m.nav_system_localization, + power: m.nav_system_power, + system: m.nav_system, + users: m.nav_admin_users }; const titleCase = (segment: string) => @@ -22,9 +28,14 @@ const crumbs = $derived.by(() => { const parts = page.url.pathname.split('/').filter(Boolean); + const machineName = machine?.current?.name; const segments = parts.map((segment, i) => { const href = '/' + parts.slice(0, i + 1).join('/'); - return { href, label: LABELS[href]?.() ?? titleCase(segment) }; + const label = + segment === page.params.machineId && machineName + ? machineName + : (LABELS[segment]?.() ?? titleCase(segment)); + return { href, label }; }); return [{ href: '/', label: LABELS['/']?.() ?? m.home() }, ...segments]; }); diff --git a/src/lib/components/blocks/sidebar/app-sidebar.svelte b/src/lib/components/blocks/sidebar/app-sidebar.svelte index 5dd45cf..45d9022 100644 --- a/src/lib/components/blocks/sidebar/app-sidebar.svelte +++ b/src/lib/components/blocks/sidebar/app-sidebar.svelte @@ -2,69 +2,15 @@ import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'; import ServerIcon from '@lucide/svelte/icons/server'; import ShieldIcon from '@lucide/svelte/icons/shield'; + import UsersIcon from '@lucide/svelte/icons/users'; import favicon from '$lib/assets/favicon.svg?raw'; import { m } from '$lib/paraglide/messages'; - const data: { - navMain: { - icon: typeof ShieldIcon; - items: { - description: () => string; - title: () => string; - url: Pathname; - }[]; - title: () => string; - url: Pathname; - }[]; - } = { - navMain: [ - { - icon: LayoutDashboardIcon, - items: [ - { - description: m.nav_dashboard_overview_desc, - title: m.nav_dashboard_overview, - url: '/dashboard' - } - ], - title: m.dashboard, - url: '/dashboard' - }, - { - icon: ShieldIcon, - items: [ - { - description: m.nav_admin_users_desc, - title: m.nav_admin_users, - url: '/admin/users' - }, - { - description: m.nav_admin_config_desc, - title: m.nav_admin_config, - url: '/admin/config' - } - ], - title: m.nav_admin, - url: '/admin' - }, - { - icon: ServerIcon, - items: [ - { - description: m.nav_system_datetime_desc, - title: m.nav_system_datetime, - url: '/system/date-time' - }, - { - description: m.nav_system_localization_desc, - title: m.nav_system_localization, - url: '/system/localization' - } - ], - title: m.nav_system, - url: '/system' - } - ] + type NavSection = { + icon: typeof ShieldIcon; + items: { description: () => string; title: () => string; url: Pathname }[]; + title: () => string; + url: Pathname; }; @@ -84,7 +30,90 @@ const isMobile = new IsMobile(); const sidebar = useSidebar(); - let mobileSection = $state<(typeof data.navMain)[number] | null>(null); + let mobileSection = $state(null); + + const navMain = $derived.by(() => { + const machineId = page.params.machineId; + const sections: NavSection[] = [ + { + icon: LayoutDashboardIcon, + items: [ + { + description: m.nav_dashboard_overview_desc, + title: m.nav_dashboard_overview, + url: '/dashboard' + } + ], + title: m.dashboard, + url: machineId ? `/dashboard/${machineId}`:'/dashboard' + }, + { + icon: ShieldIcon, + items: [ + { + description: m.nav_admin_users_desc, + title: m.nav_admin_users, + url: '/admin/users' + }, + { + description: m.nav_admin_config_desc, + title: m.nav_admin_config, + url: '/admin/config' + } + ], + title: m.nav_admin, + url: '/admin' + } + ]; + if (machineId) { + const base = `/dashboard/${machineId}/system`; + sections.splice(1,0,{ + icon: ServerIcon, + items: [ + { + description: m.nav_system_datetime_desc, + title: m.nav_system_datetime, + url: `${base}/date-time` as Pathname + }, + { + description: m.nav_system_localization_desc, + title: m.nav_system_localization, + url: `${base}/localization` as Pathname + }, + { + description: m.nav_system_hostname_desc, + title: m.nav_system_hostname, + url: `${base}/hostname` as Pathname + }, + { + description: m.nav_system_power_desc, + title: m.nav_system_power, + url: `${base}/power` as Pathname + } + ], + title: m.nav_system, + url: base as Pathname + }); + sections.splice(2, 0, { + icon: UsersIcon, + items: [ + { + description: m.nav_users_system_users_desc, + title: m.nav_users_system_users, + url: `/dashboard/${machineId}/users` as Pathname + }, + { + description: m.nav_users_groups_desc, + title: m.nav_users_groups, + url: `/dashboard/${machineId}/users/groups` as Pathname + } + ], + title: m.users_title, + url: `/dashboard/${machineId}/users` as Pathname + }); + } + return sections; + }); // Navigating from any link in either drawer should drop the user on the page, // not leave the rail + content sheets covering it. @@ -99,14 +128,14 @@ }: { user: User } & ComponentProps = $props(); const activeItem = $derived( - data.navMain.find((section) => page.url.pathname.startsWith(section.url)) ?? data.navMain[0]! + [...navMain].sort((a, b) => b.url.length - a.url.length).find((section) => + page.url.pathname.startsWith(section.url) + ) ?? navMain[0]! ); -{#snippet sectionContent(section: (typeof data.navMain)[number])} - {#if section.url === '/dashboard'} - - {:else} +{#snippet sectionContent(section: NavSection)} + {#each section.items as item (item.url)} {/each} + {#if section.url === '/dashboard'|| section.url === `/dashboard/${page.params.machineId}`} + {/if} {/snippet} @@ -157,11 +188,11 @@ - - - - {#each data.navMain as item (item.url)} - + + + + {#each navMain as item (item.url)} + + import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical'; import PlusIcon from '@lucide/svelte/icons/plus'; import { resolve } from '$app/paths'; import { page } from '$app/state'; @@ -10,29 +11,74 @@ import { IsMobile } from '$lib/hooks/is-mobile.svelte'; import { machineSchema } from '$lib/machines/schema'; import { m } from '$lib/paraglide/messages'; - import { addMachine, listMachines } from '$lib/remotes/machines.remote'; - import { auditLog, serverInfo, systemDetails } from '$lib/remotes/server.remote'; - import { tick } from 'svelte'; + import { addMachine, listMachines, reorderMachines } from '$lib/remotes/machines.remote'; + import { untrack } from 'svelte'; import { toast } from 'svelte-sonner'; + import { createSwapy } from 'swapy'; - const info = $derived(serverInfo()); - const details = $derived(systemDetails()); - const audit = $derived(auditLog()); const id = $props.id(); const isMobile = new IsMobile(); let { onnavigate }: { onnavigate?: () => void } = $props(); - // MediaQuery is false during SSR/first hydration; gate on mount so server and - // client agree on the Dialog branch, then switch to Drawer. Avoids hydration_mismatch. let search = $state(''); let pageNum = $state(1); let open = $state(false); + const PAGE_SIZE = 3; + + let listEl: HTMLDivElement | undefined = $state(); + let items: { address: string; id: string; name: null | string; }[] = $state([]); + // Recreate swapy whenever the set of ids changes (add/remove/page/search), so it + // binds to the fresh DOM. Reorders don't change the set, so swapy is left alone. + const idSet = $derived([...items.map((i) => i.id)].sort().join('|')); + + $effect(() => { + if (!listEl || !idSet) return; + const inst = createSwapy(listEl, { + animation: 'dynamic', + autoScrollOnDrag: true, + dragAxis: 'y' + }); + inst.onSwapEnd(async ({ hasChanged }) => { + if (!hasChanged) return; + // slots are fixed (= items order); read which item now sits in each slot. + const map = inst.slotItemMap().asObject; + const ids = untrack(() => items) + .map((mc) => map[mc.id]) + .filter((x): x is string => !!x); + try { + inst.enable(false) + await reorderMachines({ ids, startIndex: (pageNum - 1) * PAGE_SIZE }); + } catch (error) { + console.error(error); + toast.error(m.errors_generic()); + }finally{ + + inst.enable(true) + } + }); + return () => inst.destroy(); + }); + const onSearch = () => { pageNum = 1; }; const selectedId = $derived(page.params.machineId); const machines = $derived(listMachines({ page: pageNum, search })); + + let pageInfo: { page: number; pages: number } = $state({ page: 1, pages: 1 }); + $effect(() => { + machines.then((data) => { + pageInfo = { page: data.page, pages: data.pages }; + untrack(() => { + // Only replace items when the *set* changes. A reorder leaves the set + // identical — keep our array so swapy's DOM arrangement isn't clobbered. + const cur = [...items.map((i) => i.id)].sort().join('|'); + const next = [...data.items.map((i) => i.id)].sort().join('|'); + if (cur !== next) items = data.items; + }); + }); + }); {#snippet addForm()} @@ -134,51 +180,58 @@ {/if} - - {@const data = await machines} -
- {#each data.items as machine (machine.id)} - { - onnavigate?.(); - await tick().then(async () => { - await details.refresh(); - await audit.refresh(); - await info.refresh(); - }); - }} +
+ {#each items as machine (machine.id)} +
+ - - {#if data.pages > 1} -
- - {m.pagination_page_of({ page: data.page, pages: data.pages })} - + {machine.name} + {machine.address} + +
+ +
+
- {/if} - + {:else} +

{m.machine_none()}

+ {/each} +
+ +{#if pageInfo.pages > 1} +
+ + {m.pagination_page_of({ page: pageInfo.page, pages: pageInfo.pages })} + +
+{/if} diff --git a/src/lib/const/schema.ts b/src/lib/const/schema.ts index 4880325..0cbb9b1 100644 --- a/src/lib/const/schema.ts +++ b/src/lib/const/schema.ts @@ -16,6 +16,7 @@ const EnvSchema = v.object({ v.transform((v) => v === 'true') ), ORIGIN: v.pipe(v.optional(v.string(), 'http://localhost:5173')), + REPOSITORY_URL: v.pipe(v.string(), v.nonEmpty()), SMTP_FROM: v.pipe(v.optional(v.string())), SMTP_HOST: v.pipe(v.optional(v.string())), SMTP_PASS: v.pipe(v.optional(v.string())), diff --git a/src/lib/remotes/machines.remote.ts b/src/lib/remotes/machines.remote.ts index 22ad056..1ca166c 100644 --- a/src/lib/remotes/machines.remote.ts +++ b/src/lib/remotes/machines.remote.ts @@ -1,18 +1,17 @@ import { error } from '@sveltejs/kit'; -import { form, query } from '$app/server'; +import { command, form, query } from '$app/server'; import { v } from '$lib'; import { machineDeleteSchema, machineEditSchema, machineSchema } from '$lib/machines/schema'; import { m } from '$lib/paraglide/messages'; import { db } from '$lib/server/db'; import { machines } from '$lib/server/db/schema'; -import { asc, count, eq, like, or } from 'drizzle-orm'; +import { asc, count, eq, like, or, sql } from 'drizzle-orm'; const PAGE_SIZE = 10; export const listMachines = query( v.object({ page: v.optional(v.number(), 1), search: v.optional(v.string(), '') }), async ({ page, search }) => { - // Never select `token` — it stays server-side only. const where = search ? or(like(machines.name, `%${search}%`), like(machines.address, `%${search}%`)) : undefined; @@ -21,7 +20,7 @@ export const listMachines = query( .select({ address: machines.address, id: machines.id, name: machines.name }) .from(machines) .where(where) - .orderBy(asc(machines.name)) + .orderBy(sql`${machines.order} IS NULL`, asc(machines.order), asc(machines.name)) .limit(PAGE_SIZE) .offset((page - 1) * PAGE_SIZE), db.select({ total: count() }).from(machines).where(where) @@ -31,14 +30,43 @@ export const listMachines = query( } ); +export const reorderMachines = command( + v.object({ ids: v.array(v.string()), startIndex: v.optional(v.number(), 0) }), + async ({ ids, startIndex }) => { + // ponytail: two-phase to satisfy UNIQUE(order); negative offsets avoid collision with existing. + await db.transaction(async (tx) => { + for (const id of ids) { + await tx + .update(machines) + .set({ order: -1 - Math.floor(Math.random() * 1_000_000_000) }) + .where(eq(machines.id, id)); + } + for (const [i, mid] of ids.entries()) { + await tx + .update(machines) + .set({ order: startIndex + i }) + .where(eq(machines.id, mid)); + } + }); + return { ok: true }; + } +); + +export const getMachine = query(v.string(), async (id) => { + const row = await db + .select({ address: machines.address, id: machines.id, name: machines.name }) + .from(machines) + .where(eq(machines.id, id)) + .limit(1); + return row[0] ?? null; +}); + export const addMachine = form(machineSchema, async ({ address, name, token }) => { await db.insert(machines).values({ address, name, token }); return { ok: true }; }); export const updateMachine = form(machineEditSchema, async ({ address, id, name, token }) => { - // Only patch the fields the user actually changed — blanks mean "keep current" - // (the form pre-fills name/address but doesn't push them through the form state). const patch: { address?: string; name?: string; token?: string } = {}; if (name) patch.name = name; if (address) { diff --git a/src/lib/remotes/pam-users.remote.ts b/src/lib/remotes/pam-users.remote.ts new file mode 100644 index 0000000..4017168 --- /dev/null +++ b/src/lib/remotes/pam-users.remote.ts @@ -0,0 +1,110 @@ +import { error } from '@sveltejs/kit'; +import { command, query } from '$app/server'; +import { v } from '$lib'; +import { m } from '$lib/paraglide/messages'; + +import { nadirForMachine } from './utils'; + + + +export const listPamUsers = query(v.string(), async (machineId) => { + const nadir = await nadirForMachine(machineId); + const { data, error: err } = await nadir.GET('/api/users'); + if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() }); + return data.users ?? []; +}); + +export const createPamUser = command( + v.object({ + comment: v.optional(v.string()), + create_home: v.optional(v.boolean()), + home: v.optional(v.string()), + machineId: v.string(), + shell: v.optional(v.string()), + system: v.optional(v.boolean()), + username: v.string() + }), + async (body) => { + const nadir = await nadirForMachine(body.machineId); + const { error: err } = await nadir.POST('/api/users', { body }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await listPamUsers(body.machineId).refresh(); + } +); + +export const deletePamUser = command( + v.object({ machineId:v.string(),remove_home: v.optional(v.boolean()), username: v.string() }), + async ({ machineId, remove_home, username }) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.DELETE('/api/users/{username}', { + params: { path: { username }, query: { remove_home } } + }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await listPamUsers(machineId).refresh(); + } +); + +export const setPamUserPassword = command( + v.object({ machineId:v.string(),password: v.string(), username: v.string() }), + async ({machineId, password, username }) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/users/{username}/password', { + body: { password }, + params: { path: { username } } + }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + } +); + +export const getPamUser = query(v.object({machineId:v.string(), username:v.string()}), async ({machineId,username}) => { + const nadir = await nadirForMachine(machineId,); + const { data, error: err } = await nadir.GET('/api/users/{username}', { + params: { path: { username } } + }); + if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() }); + return data; +}); + +export const setPamUserGroups = command( + v.object({ groups: v.array(v.string()),machineId:v.string(), username: v.string() }), + async ({ groups, machineId, username }) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.PUT('/api/users/{username}/groups', { + body: { groups }, + params: { path: { username } } + }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await listPamGroups(machineId,).refresh(); + } +); + +export const listPamGroups = query(v.string(),async (machineId) => { + const nadir = await nadirForMachine(machineId); + const { data, error: err } = await nadir.GET('/api/groups'); + if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() }); + return data.groups ?? []; +}); + +export const createPamGroup = command( + v.object({ + gid: v.optional(v.number()), + machineId: v.string(), + name: v.string(), + system: v.optional(v.boolean()) + }), + async (body) => { + const nadir = await nadirForMachine(body.machineId); + const { error: err } = await nadir.POST('/api/groups', { body }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await listPamGroups(body.machineId).refresh(); + } +); + +export const deletePamGroup = command(v.object({group:v.string(), machineId:v.string()}), async ({group, machineId}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.DELETE('/api/groups/{group}', { + params: { path: { group } } + }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await listPamGroups(machineId).refresh(); +}); diff --git a/src/lib/remotes/server.remote.ts b/src/lib/remotes/server.remote.ts index e410b24..7beb61b 100644 --- a/src/lib/remotes/server.remote.ts +++ b/src/lib/remotes/server.remote.ts @@ -1,15 +1,15 @@ import { error } from '@sveltejs/kit'; import { command, getRequestEvent, query } from '$app/server'; import { v } from '$lib'; +import { env } from '$lib/const/schema'; import { m } from '$lib/paraglide/messages'; import { db } from '$lib/server/db'; import { decryptValue } from '$lib/server/db/custom-types'; import { getClient } from '$lib/server/nadir-agent/client'; -export const serverInfo = query(async () => { +export const serverInfo = query(v.string(),async (machineId) => { const { locals: { user }, - params: { machineId } } = getRequestEvent(); if (!user) error(401, { message: m.errors_unauthenticated() }); const machine = await db.query.machines.findFirst({ where: { id: machineId } }); @@ -39,10 +39,9 @@ export const serverInfo = query(async () => { } }); -export const auditLog = query(v.optional(v.number(), 20), async (limit) => { +export const auditLog = query(v.object({limit:v.optional(v.number(), 20),machineId:v.string()}), async ({limit,machineId}) => { const { locals: { user }, - params: { machineId } } = getRequestEvent(); if (!user) error(401, { message: m.errors_unauthenticated() }); const machine = await db.query.machines.findFirst({ where: { id: machineId } }); @@ -58,13 +57,12 @@ export const auditLog = query(v.optional(v.number(), 20), async (limit) => { } }); -// ponytail: in-memory cache, 10 min; switch to Redis/KV if we ever run >1 node -let latestCache: { at: number; tag: string | null } | null = null; +let latestCache: { at: number; tag: null | string } | null = null; export const latestAgentRelease = query(async () => { - if (latestCache && Date.now() - latestCache.at < 10 * 60_000) return latestCache.tag; + if (latestCache && Date.now() - latestCache.at < 60_000) return latestCache.tag; try { const r = await fetch( - 'https://tea.urania.dev/api/v1/repos/urania/nadir-agent/releases/latest' + env.REPOSITORY_URL ); const tag = r.ok ? (((await r.json()) as { tag_name?: string }).tag_name ?? null) : null; latestCache = { at: Date.now(), tag }; @@ -74,10 +72,9 @@ export const latestAgentRelease = query(async () => { } }); -export const updateAgent = command(async () => { +export const updateAgent = command(v.string(),async (machineId) => { const { locals: { user }, - params: { machineId } } = getRequestEvent(); if (!user) error(401, { message: m.errors_unauthenticated() }); const machine = await db.query.machines.findFirst({ where: { id: machineId } }); @@ -88,11 +85,9 @@ export const updateAgent = command(async () => { const { error: err } = await nadir.POST('/api/update'); if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); }); - -export const systemDetails = query(async () => { +export const systemDetails = query(v.string(),async (machineId,) => { const { locals: { user }, - params: { machineId } } = getRequestEvent(); if (!user) error(401, { message: m.errors_unauthenticated() }); const machine = await db.query.machines.findFirst({ where: { id: machineId } }); diff --git a/src/lib/remotes/system.remote.ts b/src/lib/remotes/system.remote.ts new file mode 100644 index 0000000..cade188 --- /dev/null +++ b/src/lib/remotes/system.remote.ts @@ -0,0 +1,138 @@ +import { error } from '@sveltejs/kit'; +import { command, getRequestEvent, query } from '$app/server'; +import { v } from '$lib'; +import { m } from '$lib/paraglide/messages'; +import { db } from '$lib/server/db'; +import { decryptValue } from '$lib/server/db/custom-types'; +import { getClient } from '$lib/server/nadir-agent/client'; + +import { systemDetails } from './server.remote'; + + +async function nadirForMachine(machineId:string) { + const { + locals: { user }, + } = getRequestEvent(); + if (!user) error(401, { message: m.errors_unauthenticated() }); + const machine = await db.query.machines.findFirst({ where: { id: machineId } }); + if (!machine) error(404, { message: m.errors_not_found() }); + const token = decryptValue(machine.token); + if (!token) error(500, { message: m.errors_generic() }); + return getClient(machine.address, token); +} + +export const systemTime = query(v.string(), async (machineId) => { + const nadir = await nadirForMachine(machineId); + const { data, error: err } = await nadir.GET('/api/system/time'); + if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() }); + return data; +}); + +export const systemLocale = query(v.string(), async (machineId) => { + const nadir = await nadirForMachine(machineId); + const { data, error: err } = await nadir.GET('/api/system/locale'); + if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() }); + return data; +}); + +export const listTimezones = query(v.string(), async (machineId) => { + const nadir = await nadirForMachine(machineId); + const { data } = await nadir.GET('/api/system/timezones'); + return data?.timezones ?? []; +}); + +export const listLocales = query(v.string(), async (machineId) => { + const nadir = await nadirForMachine(machineId); + const { data } = await nadir.GET('/api/system/locales'); + return data?.locales ?? []; +}); + +export const setTimezone = command(v.object({machineId:v.string(), timezone:v.string()}), async ({machineId,timezone}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/timezone', { body: { timezone } }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await systemTime(machineId).refresh(); + await systemDetails(machineId).refresh(); +}); + +export const setNtp = command(v.object({enabled:v.boolean(), machineId:v.string()}), async ({enabled,machineId}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/ntp', { body: { enabled } }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await systemTime(machineId).refresh(); + await systemDetails(machineId).refresh(); +}); + +export const setTime = command(v.object({machineId:v.string(), time:v.string()}), async ({machineId,time}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/time', { body: { time } }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await systemTime(machineId).refresh(); +}); + +export const systemHostname = query(v.string(), async (machineId) => { + const nadir = await nadirForMachine(machineId); + const { data, error: err } = await nadir.GET('/api/system/hostname'); + if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() }); + return data; +}); + +export const setHostname = command(v.object({hostname:v.string(), machineId:v.string()}), async ({hostname,machineId}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/hostname', { body: { hostname } }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await systemHostname(machineId).refresh(); +}); + +export const listKeymaps = query(v.string(), async (machineId) => { + const nadir = await nadirForMachine(machineId); + const { data } = await nadir.GET('/api/system/keymaps'); + const d = data as { keymaps?: null | string[]; reason?: string } | undefined; + return { keymaps: d?.keymaps ?? [], reason: d?.reason ?? '' }; +}); + +export const setKeymap = command(v.object({keymap: v.string(),machineId:v.string()}), async ({keymap,machineId}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/keymap', { body: { keymap } }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await systemLocale(machineId).refresh(); + await systemDetails(machineId).refresh(); +}); + +export const powerOff = command(v.object({machineId:v.string(), when:v.optional(v.string(),"")}), async ({machineId,when}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/poweroff', { body: { when } }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); +}); + +export const reboot = command(v.object({machineId:v.string(), when:v.optional(v.string(), '')}), async ({machineId,when}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/reboot', { body: { when } }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); +}); + +export const setLocale = command( + v.object({ + lang: v.string(), + language: v.optional(v.string()), + machineId:v.string() + }), + async ({ lang,language , machineId }) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/locale', { + body: { lang, language: language || undefined } + }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await systemLocale(machineId).refresh(); + await systemDetails(machineId).refresh(); + } +); + + +export const generateLocale = command(v.object({locale:v.string(), machineId:v.string()}), async ({locale,machineId}) => { + const nadir = await nadirForMachine(machineId); + const { error: err } = await nadir.POST('/api/system/locale/generate', { body: { locale } }); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + await listLocales(machineId).refresh(); +}); + diff --git a/src/lib/remotes/utils.ts b/src/lib/remotes/utils.ts new file mode 100644 index 0000000..72f45e9 --- /dev/null +++ b/src/lib/remotes/utils.ts @@ -0,0 +1,18 @@ +import { error } from "@sveltejs/kit"; +import { getRequestEvent } from "$app/server"; +import { m } from "$lib/paraglide/messages"; +import { db } from "$lib/server/db"; +import { decryptValue } from "$lib/server/db/custom-types"; +import { getClient } from "$lib/server/nadir-agent/client"; + +export const nadirForMachine = async(machineId:string) => { + const { + locals: { user }, + } = getRequestEvent(); + if (!user) error(401, { message: m.errors_unauthenticated() }); + const machine = await db.query.machines.findFirst({ where: { id: machineId } }); + if (!machine) error(404, { message: m.errors_not_found() }); + const token = decryptValue(machine.token); + if (!token) error(500, { message: m.errors_generic() }); + return getClient(machine.address, token); +} \ No newline at end of file diff --git a/src/lib/server/nadir-agent/schema.d.ts b/src/lib/server/nadir-agent/schema.d.ts index d4e6ce5..1f5555c 100644 --- a/src/lib/server/nadir-agent/schema.d.ts +++ b/src/lib/server/nadir-agent/schema.d.ts @@ -784,6 +784,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/system/locale/generate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate (install) a new locale + * @description Generates a locale so it becomes available for use with set-locale. On Debian/Ubuntu/Arch this uncomments the entry in /etc/locale.gen and runs locale-gen; on RHEL/Fedora it uses localedef. Idempotent: if the locale is already generated, returns 200 immediately. + */ + post: operations["system-generate-locale"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/system/locales": { parameters: { query?: never; @@ -1360,6 +1380,19 @@ export interface components { */ pass: number; }; + GenerateLocaleInputBody: { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example https://example.com/schemas/GenerateLocaleInputBody.json + */ + readonly $schema?: string; + /** + * @description Locale to generate (e.g. fr_FR.UTF-8) + * @example fr_FR.UTF-8 + */ + locale: string; + }; Group: { /** * Format: uri @@ -1380,7 +1413,7 @@ export interface components { * @example wheel */ name: string; - /** @description True for system groups (gid < 1000) */ + /** @description True for system groups (g 1000) */ system: boolean; }; HealthOutputBody: { @@ -1574,6 +1607,8 @@ export interface components { readonly $schema?: string; /** @description Available virtual console keymaps */ keymaps: string[] | null; + /** @description When keymaps is empty, why: e.g. "kbd not installed on this server" */ + reason?: string; }; ListFstabOutputBody: { /** @@ -1695,6 +1730,11 @@ export interface components { * @example it_IT.UTF-8 */ lang: string; + /** + * @description Fallback language list (LANGUAGE) + * @example en_US: + */ + language: string; /** * @description Virtual console keymap * @example it @@ -2081,6 +2121,11 @@ export interface components { * @example it_IT.UTF-8 */ lang: string; + /** + * @description Fallback language list (LANGUAGE) + * @example en_US: + */ + language?: string; }; SetNTPInputBody: { /** @@ -2255,7 +2300,7 @@ export interface components { * @example /bin/bash */ shell: string; - /** @description True for system accounts (uid < 1000) */ + /** @description True for system accounts (u 1000) */ system: boolean; /** * Format: int64 @@ -5259,6 +5304,84 @@ export interface operations { }; }; }; + "system-generate-locale": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GenerateLocaleInputBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusOutputBody"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; "system-list-locales": { parameters: { query?: never; @@ -5835,6 +5958,15 @@ export interface operations { "application/json": components["schemas"]["StatusOutputBody"]; }; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; /** @description Unauthorized */ 401: { headers: { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f896c38..dd02f22 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -17,17 +17,19 @@ let { children } = $props(); const user = $derived(getUser()); + let showSidebar =$derived( (cU:null|User)=> cU && (page.url.pathname.startsWith('/dashboard')||page.url.pathname.startsWith('/admin'))) {@const currentUser = await user} - {#if currentUser} + {@const show = currentUser && showSidebar(currentUser)} + {#if show } {/if} - {#if await user} + {#if show}
diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts index 4db612b..078a0a3 100644 --- a/src/routes/admin/+layout.server.ts +++ b/src/routes/admin/+layout.server.ts @@ -1,3 +1 @@ -// Forces hooks.server.ts to run on every /admin navigation (client-side router -// skips the server otherwise when no server load is defined). export const load = () => {}; diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte index 0929177..39da071 100644 --- a/src/routes/admin/users/+page.svelte +++ b/src/routes/admin/users/+page.svelte @@ -194,11 +194,17 @@ } -
-
-
-

{m.users_title()}

-

{m.users_description()}

+
+
+
+

+ {m.users_title()} +

+

+ {m.users_description()} +

@@ -214,10 +220,10 @@ {/snippet} -
+

{m.users_filter_title()}

-
+
-
+
@@ -268,7 +274,7 @@
-
+
-
+
{m.users_rows_per_page()} @@ -328,7 +334,7 @@
-
+
@@ -365,7 +371,7 @@
-
+
- -
- - - - {#each [{ key: 'name', label: m.name() }, { key: 'username', label: m.username() }, { key: 'email', label: m.email() }, { key: 'createdAt', label: m.users_created_at() }] as col (col.key)} - - - - {/each} - {m.users_role()} - {m.users_status()} - {m.users_actions()} - - - - {#if usersQuery.loading && !data.users.length} +
+
+ + - - - {:else if !data.users.length} - - {m.users_no_results()} - - {:else} - {#each data.users as u (u.id)} - - {u.name} - {u.username ?? '—'} - {u.email} - {new Date(u.createdAt).toLocaleDateString()} - - - {u.role === 'admin' ? m.users_role_admin() : m.users_role_user()} - - - - {#if u.banned} - {m.users_banned()} - {:else if !u.emailVerified} - - {m.users_pending()} - - {:else} - {m.users_active()} - {/if} - - - - - {#snippet child({ props })} - - {/snippet} - - - openEdit(u)} - >{m.users_edit()} - {#if !u.emailVerified} - doResendInvite(u)} - >{m.users_resend_invite()} + {#each [{ key: 'name', label: m.name() }, { key: 'username', label: m.username() }, { key: 'email', label: m.email() }, { key: 'createdAt', label: m.users_created_at() }] as col (col.key)} + + + + {/each} + {m.users_role()} + {m.users_status()} + {m.users_actions()} + + + + {#if usersQuery.loading && !data.users.length} + + - {/each} - {/if} - - + {:else if !data.users.length} + + {m.users_no_results()} + + {:else} + {#each data.users as u (u.id)} + + {u.name} + {u.username ?? '—'} + {u.email} + {new Date(u.createdAt).toLocaleDateString()} + + + {u.role === 'admin' ? m.users_role_admin() : m.users_role_user()} + + + + {#if u.banned} + {m.users_banned()} + {:else if !u.emailVerified} + + {m.users_pending()} + + {:else} + {m.users_active()} + {/if} + + + + + {#snippet child({ props })} + + {/snippet} + + + openEdit(u)} + >{m.users_edit()} + {#if !u.emailVerified} + doResendInvite(u)} + >{m.users_resend_invite()} + {/if} + toggleBan(u)}> + {u.banned ? m.users_unban() : m.users_ban()} + + + openDelete(u)}> + {m.users_delete()} + + + + + + {/each} + {/if} + + +
-
+
{m.users_page_of({ page, total: totalPages })} i.value === syncInterval)?.label); const poll = async () => { - console.log('refreshed poll'); - await serverInfo().refresh(); - await auditLog().refresh(); + await serverInfo(machineId).refresh(); + await auditLog({limit:undefined, machineId}).refresh(); }; const refreshAll = async () => { - console.log('refreshed all'); await poll(); - await systemDetails().refresh(); + await systemDetails(machineId).refresh(); }; let polling = $state(true); - // $effect re-runs when rate or polling toggle, so changing the selector takes - // effect immediately and pausing actually stops the timer. + $effect(() => { if (!polling || intervalMs <= 0) return; const id = setInterval(poll, intervalMs); @@ -217,7 +217,7 @@ {sys.$db.name}

- {sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address} + {sys.os.hostname} | {sys.os.pretty_name} | {sys.$agent_version} | {sys.$db.address}

@@ -295,7 +295,7 @@ const from = agentVersion ?? '?'; const to = latestTag ?? '?'; try { - await updateAgent(); + await updateAgent(machineId); toast.info(m.agent_update_started({ from, to })); // Agent returns 202 and runs update in background; poll until the // reported version flips or we give up. @@ -387,8 +387,8 @@ detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })} />
-
-
+
+
@@ -402,7 +402,7 @@ />
-
+
@@ -518,6 +518,9 @@ + {#snippet pending()} + + {/snippet}
diff --git a/src/routes/dashboard/[machineId]/system/+page.svelte b/src/routes/dashboard/[machineId]/system/+page.svelte new file mode 100644 index 0000000..7c477f7 --- /dev/null +++ b/src/routes/dashboard/[machineId]/system/+page.svelte @@ -0,0 +1,63 @@ + + + diff --git a/src/routes/dashboard/[machineId]/system/date-time/+page.svelte b/src/routes/dashboard/[machineId]/system/date-time/+page.svelte new file mode 100644 index 0000000..fbc6024 --- /dev/null +++ b/src/routes/dashboard/[machineId]/system/date-time/+page.svelte @@ -0,0 +1,164 @@ + + +
+

{m.nav_system_datetime()}

+ + + {#snippet failed(err)} + + {(err as Error).message} + + {/snippet} + {@const t = await time} + {@const zones = await tzs} + + + + {m.system_time_current()} + {t.time} + + + + + + {m.system_time_timezone()} + {t.timezone} + + + + + {#snippet child({ props })} + + {/snippet} + + + + + + {m.system_time_no_timezone_found()} + {#each zones as z (z)} + { + tzOpen = false; + if (z === t.timezone) return; + await withSaving(() => setTimezone({machineId,timezone:z})); + }} + > + + {z} + + {/each} + + + + + + + + + + {m.system_time_ntp()} + {m.system_time_ntp_hint()} + + +
+ {t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()} +
+ withSaving(() => setNtp({enabled:v, machineId}))} + /> +
+
+ + + + {m.system_time_manual()} + {m.system_time_manual_hint()} + + +
{ + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const v = String(fd.get('time') ?? '').trim(); + if (!v) return; + await withSaving(() => setTime({machineId,time:rfc3339FromLocal(v)})); + }} + > +
+ + +
+ +
+
+
+
+
diff --git a/src/routes/dashboard/[machineId]/system/hostname/+page.svelte b/src/routes/dashboard/[machineId]/system/hostname/+page.svelte new file mode 100644 index 0000000..851e119 --- /dev/null +++ b/src/routes/dashboard/[machineId]/system/hostname/+page.svelte @@ -0,0 +1,76 @@ + + +
+

{m.nav_system_hostname()}

+ + + {#snippet failed(err)} + + {(err as Error).message} + + {/snippet} + {@const h = await host} + + + + {m.system_hostname_current()} + {h.hostname} + + +
{ + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const v = String(fd.get('hostname') ?? '').trim(); + if (!HOSTNAME_RE.test(v)) { + toast.error(m.system_hostname_invalid()); + return; + } + if (v === h.hostname) return; + saving = true; + try { + await setHostname({hostname:v,machineId,}); + toast.success(m.saved()); + } catch (err) { + toast.error((err as Error).message || 'Error'); + } finally { + saving = false; + } + }} + > +
+ + +
+ +
+
+
+
+
diff --git a/src/routes/dashboard/[machineId]/system/localization/+page.svelte b/src/routes/dashboard/[machineId]/system/localization/+page.svelte new file mode 100644 index 0000000..ddfbb49 --- /dev/null +++ b/src/routes/dashboard/[machineId]/system/localization/+page.svelte @@ -0,0 +1,296 @@ + + +
+

{m.nav_system_localization()}

+ + + {#snippet failed(err)} + + {(err as Error).message} + + {/snippet} + {@const l = await locale} + {@const list = await locales} + {@const kms = await keymaps} + + + + {m.system_locale_lang()} + {l.lang} + + + + + {#snippet child({ props })} + + {/snippet} + + + + + + {m.system_locale_no_locale_found()} + {#each list as loc (loc)} + { + open = false; + if (loc !== l.lang) + pick(() => setLocale({ lang: loc, language: l.language || undefined,machineId })); + }} + > + + {loc} + + {/each} + + + + + + + + + + {m.system_locale_language()} + {m.system_locale_language_desc()} + + +
+ {#each langList as loc, i (loc + i)} + + {loc} + + + {:else} + {m.system_locale_language_empty()} + {/each} + + + + {#snippet child({ props })} + + {/snippet} + + + + + + {m.system_locale_no_locale_found()} + {#each list as loc (loc)} + { + langOpen = false; + if (!langList.includes(loc)) langList = [...langList, loc]; + }} + > + + {loc} + + {/each} + + + + +
+
+ +
+
+
+ + + + {m.system_locale_keymap()} + {l.vc_keymap || '—'} + + + {#if kms.reason && kms.keymaps.length === 0} +

{kms.reason}

+ {:else} + + + {#snippet child({ props })} + + {/snippet} + + + + + + {m.system_locale_no_keymap_found()} + {#each kms.keymaps as km (km)} + { + kmOpen = false; + if (km !== l.vc_keymap) pick(() => setKeymap({keymap:km,machineId})); + }} + > + + {km} + + {/each} + + + + + {/if} +
+
+ + + + {m.system_locale_x11()} + {l.x11_layout || '—'} + + + + + + {m.system_locale_generate()} + {m.system_locale_generate_desc()} + + +
+ + +
+
+
+
+
diff --git a/src/routes/dashboard/[machineId]/system/power/+page.svelte b/src/routes/dashboard/[machineId]/system/power/+page.svelte new file mode 100644 index 0000000..4f15ec8 --- /dev/null +++ b/src/routes/dashboard/[machineId]/system/power/+page.svelte @@ -0,0 +1,76 @@ + + +
+

{m.nav_system_power()}

+ + + + {m.nav_system_power()} + {m.nav_system_power_desc()} + + + + + + +
+ + { + if (!v) pending = null; + }} +> + + + + {pending === 'off' ? m.system_power_confirm_poweroff_title() : m.system_power_confirm_reboot_title()} + + {m.system_power_confirm_description()} + + + {m.cancel()} + pending && run(pending)} + > + {pending === 'off' ? m.system_power_poweroff() : m.system_power_reboot()} + + + + diff --git a/src/routes/dashboard/[machineId]/users/+page.svelte b/src/routes/dashboard/[machineId]/users/+page.svelte new file mode 100644 index 0000000..ba3c116 --- /dev/null +++ b/src/routes/dashboard/[machineId]/users/+page.svelte @@ -0,0 +1,512 @@ + + +
+
+
+

{m.users_nav_title()}

+

+ {m.users_nav_description()} +

+
+
+ + + {#snippet child({ props })} + + {/snippet} + + +
+

{m.users_filter_title()}

+
+
+ + +
+
+ +
+ {m.users_rows_per_page()} + { + const n = Number((e.target as HTMLInputElement).value); + if (n >= 1) { + pageSize.current = n; + page = 1; + } + }} + /> + + {#each [10, 25, 50, 100, 200] as n (n)} + + {/each} + +
+
+
+
+ + +
+
+ +
+ + + {filtered.length} / {users.current?.length ?? 0} + +
+ +
+
+ + + + {#each [{ key: 'username', label: m.username() }, { key: 'uid', label: 'UID' }, { key: 'shell', label: 'Shell' }] as col (col.key)} + + + + {/each} + {m.users_col_comment()} + {m.users_col_home()} + {m.users_col_type()} + {m.users_actions()} + + + + {#if users.loading && !users.current} + + + + {:else if !pageRows.length} + + {m.users_no_results()} + + {:else} + {#each pageRows as u (u.username)} + + + {u.username} + + {u.uid} + {u.shell} + {u.comment || '—'} + {u.home} + + {#if u.system} + {m.users_type_system()} + {:else} + {m.users_type_user()} + {/if} + + + + + {#snippet child({ props })} + + {/snippet} + + + { + pwUser = u; + pwValue = ''; + pwOpen = true; + }}>{m.users_action_set_password()} + + { + deleting = u; + removeHome = false; + deleteOpen = true; + }}>{m.users_delete()} + + + + + {/each} + {/if} + + +
+
+ +
+
+ {m.users_page_of({ page, total: totalPages })} + + +
+
+
+ + + + + {m.users_create_title()} + {m.users_pam_create_description()} + +
{ + e.preventDefault(); + doCreate(); + }} + class="flex flex-col gap-3" + > +
+ + +
+
+ + +
+
+ + +
+ + + + + + +
+
+
+ + + + + {m.users_set_password_title({ username: pwUser?.username ?? '' })} + {m.users_set_password_description()} + +
{ + e.preventDefault(); + doSetPassword(); + }} + class="flex flex-col gap-3" + > +
+ + +
+ + + + +
+
+
+ + + + + {m.users_delete_title({ username: deleting?.username ?? '' })} + {m.users_delete_description()} + + + + {m.cancel()} + {m.users_delete()} + + + diff --git a/src/routes/dashboard/[machineId]/users/[username]/+page.svelte b/src/routes/dashboard/[machineId]/users/[username]/+page.svelte new file mode 100644 index 0000000..e943f08 --- /dev/null +++ b/src/routes/dashboard/[machineId]/users/[username]/+page.svelte @@ -0,0 +1,265 @@ + + +
+ + {#snippet failed(err)} + + {(err as Error).message} + + {/snippet} + {@const u = await user} + +
+
+

+ {u.username} + {#if u.system}{m.users_type_system()}{:else}{m.users_type_user()}{/if} +

+

{u.comment || m.users_no_gecos()}

+
+
+ + +
+
+ + + + {m.users_details()} + + +
+
UID
+
{u.uid}
+
{m.users_primary_gid()}
+
+ {u.gid}{#if primary} + ({primary.name}) + {/if} +
+
{m.users_col_home()}
+
{u.home}
+
{m.users_create_field_shell()}
+
{u.shell}
+
+
+
+ + + +
+ {m.users_groups_title()} + + + {@html m.users_pam_groups_description()} + +
+ {#if !editing} + + {:else} +
+ + +
+ {/if} +
+ + {#if !editing} +
+ {#if primary} + {primary.name} {m.users_group_primary_badge()} + {/if} + {#each supplementary as name (name)} + {name} + {:else} + {#if !primary} + {m.users_no_groups()} + {/if} + {/each} +
+ {:else} +
+
+ {#each (groups.current ?? []).filter((g) => g.gid !== u.gid) as g (g.name)} + + {/each} +
+
+ {/if} +
+
+
+
+ + + + + {m.users_set_password_title({ username })} + {m.users_set_password_description()} + +
{ + e.preventDefault(); + doSetPassword(); + }} + class="flex flex-col gap-3" + > +
+ + +
+ + + + +
+
+
+ + + + + {m.users_delete_title({ username })} + {m.users_delete_description()} + + + + {m.cancel()} + {m.users_delete()} + + + diff --git a/src/routes/dashboard/[machineId]/users/groups/+page.svelte b/src/routes/dashboard/[machineId]/users/groups/+page.svelte new file mode 100644 index 0000000..6fbbc30 --- /dev/null +++ b/src/routes/dashboard/[machineId]/users/groups/+page.svelte @@ -0,0 +1,400 @@ + + +
+
+
+

{m.groups_nav_title()}

+

+ {m.groups_nav_description()} +

+
+
+ + + {#snippet child({ props })} + + {/snippet} + + +
+

{m.users_filter_title()}

+
+
+ +
+
+ +
+ {m.users_rows_per_page()} + { + const n = Number((e.target as HTMLInputElement).value); + if (n >= 1) { + pageSize.current = n; + page = 1; + } + }} + /> +
+
+
+
+ + +
+
+ +
+ + + {filtered.length} / {groups.current?.length ?? 0} + +
+ +
+
+ + + + {#each [{ key: 'name', label: m.name() }, { key: 'gid', label: 'GID' }, { key: 'members', label: 'Members' }] as col (col.key)} + + + + {/each} + Type + {m.users_actions()} + + + + {#if groups.loading && !groups.current} + + + + {:else if !pageRows.length} + + {m.groups_no_results()} + + {:else} + {#each pageRows as g (g.name)} + + + {g.name} + + {g.gid} + + {#if g.members?.length} + + {g.members.slice(0, 3).join(', ')}{g.members.length > 3 + ? ` +${g.members.length - 3}` + : ''} + + {:else} + — + {/if} + + + {#if g.system} + {m.users_type_system()} + {:else} + {m.users_type_user()} + {/if} + + + + + {#snippet child({ props })} + + {/snippet} + + + { + deleting = g; + deleteOpen = true; + }}>{m.users_delete()} + + + + + {/each} + {/if} + + +
+
+ +
+
+ {m.users_page_of({ page, total: totalPages })} + + +
+
+
+ + + + + {m.groups_create_title()} + {m.groups_create_description()} + +
{ + e.preventDefault(); + doCreate(); + }} + class="flex flex-col gap-3" + > +
+ + +
+
+ + +
+ + + + + +
+
+
+ + + + + {m.groups_delete_title({ name: deleting?.name ?? '' })} + {m.groups_delete_description()} + + + {m.cancel()} + {m.users_delete()} + + + diff --git a/src/routes/dashboard/[machineId]/users/groups/[group]/+page.svelte b/src/routes/dashboard/[machineId]/users/groups/[group]/+page.svelte new file mode 100644 index 0000000..df21552 --- /dev/null +++ b/src/routes/dashboard/[machineId]/users/groups/[group]/+page.svelte @@ -0,0 +1,244 @@ + + +
+ {#if groups.loading && !groups.current} + + + + {:else if !group} + + {m.groups_not_found({ name: groupName })} + + {:else} +
+
+

+ {group.name} + {#if group.system}{m.users_type_system()}{:else}{m.users_type_user()}{/if} +

+

gid {group.gid}

+
+ +
+ + + +
+ {m.groups_members_title()} + + + {@html m.groups_members_description({ gid: group.gid })} + +
+ +
+ +
+ + {#if members.length} +
+ {#each members as name (name)} + + {name} + + + {/each} +
+ {:else} + {m.groups_no_supplementary_members()} + {/if} +
+ +
+ + {#if primaryMembers.length} +
+ {#each primaryMembers as name (name)} + + {name} + + {/each} +
+ {:else} + {m.groups_primary_empty()} + {/if} +
+
+
+ {/if} +
+ + + + + {m.groups_add_member_title({ name: groupName })} + {m.groups_add_member_description()} + +
+ +
+ {#if !filteredCandidates.length} +
{m.groups_add_member_no_results()}
+ {:else} + {#each filteredCandidates as name (name)} + + {/each} + {/if} +
+
+ + + +
+
+ + + + + {m.groups_delete_title({ name: groupName })} + {m.groups_delete_description()} + + + {m.cancel()} + {m.users_delete()} + + +