feat: implement drag-and-drop machine reordering with swapy and add new system administration management routes
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
+204
-100
@@ -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 <code>usermod -G</code> 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 <span class=capitalize>{social}</span>",
|
||||
"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 <a class='link' href={terms}>Terms of Service</a> and <a class='link' href={privacy}>Privacy Policy</a>.",
|
||||
"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 <code>usermod -G</code>. 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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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, () => 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];
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -84,7 +30,90 @@
|
||||
|
||||
const isMobile = new IsMobile();
|
||||
const sidebar = useSidebar();
|
||||
let mobileSection = $state<(typeof data.navMain)[number] | null>(null);
|
||||
let mobileSection = $state<NavSection | null>(null);
|
||||
|
||||
const navMain = $derived.by<NavSection[]>(() => {
|
||||
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<typeof Sidebar.Root> = $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]!
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet sectionContent(section: (typeof data.navMain)[number])}
|
||||
{#if section.url === '/dashboard'}
|
||||
<MachinesNav onnavigate={closeMobileSidebars} />
|
||||
{:else}
|
||||
{#snippet sectionContent(section: NavSection)}
|
||||
|
||||
{#each section.items as item (item.url)}
|
||||
<a
|
||||
href={resolve(item.url)}
|
||||
@@ -122,6 +151,8 @@
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#if section.url === '/dashboard'|| section.url === `/dashboard/${page.params.machineId}`}
|
||||
<MachinesNav onnavigate={closeMobileSidebars} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -157,11 +188,11 @@
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupContent class="px-1.5 md:px-0">
|
||||
<Sidebar.Menu class="space-y-2">
|
||||
{#each data.navMain as item (item.url)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.Group class="h-full">
|
||||
<Sidebar.GroupContent class="px-1.5 h-full md:px-0">
|
||||
<Sidebar.Menu class="space-y-2 h-full">
|
||||
{#each navMain as item (item.url)}
|
||||
<Sidebar.MenuItem class="last-of-type:mt-auto">
|
||||
<Sidebar.MenuButton
|
||||
tooltipContentProps={{ hidden: false }}
|
||||
isActive={activeItem.url === item.url}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet addForm()}
|
||||
@@ -134,51 +180,58 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<svelte:boundary>
|
||||
{@const data = await machines}
|
||||
<div class="flex flex-col">
|
||||
{#each data.items as machine (machine.id)}
|
||||
<a
|
||||
href={resolve(`/dashboard/[machineId]`, { machineId: machine.id })}
|
||||
onclick={async () => {
|
||||
onnavigate?.();
|
||||
await tick().then(async () => {
|
||||
await details.refresh();
|
||||
await audit.refresh();
|
||||
await info.refresh();
|
||||
});
|
||||
}}
|
||||
<div bind:this={listEl} class="flex flex-col">
|
||||
{#each items as machine (machine.id)}
|
||||
<div data-swapy-slot={machine.id}>
|
||||
<div
|
||||
data-swapy-item={machine.id}
|
||||
class="group/item flex items-center justify-between border-b last:border-b-0 hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground rounded-l-2xl transition-colors"
|
||||
data-active={selectedId === machine.id}
|
||||
class="group/link rounded-l-2xl hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground flex flex-col items-start gap-1 border-b p-4 text-sm leading-tight transition-all last:border-b-0"
|
||||
>
|
||||
<span class="font-medium transition-all">{machine.name}</span>
|
||||
<span
|
||||
class="text-foreground group-hover/link:text-tertiary-foreground group-data-[active=true]/link:text-tertiary-foreground transition-all text-xs"
|
||||
>{machine.address}</span
|
||||
<a
|
||||
href={resolve(`/dashboard/[machineId]`, { machineId: machine.id })}
|
||||
onclick={async () => {
|
||||
onnavigate?.();
|
||||
}}
|
||||
draggable="false"
|
||||
data-swapy-no-drag
|
||||
class="flex-1 flex flex-col items-start gap-1 p-4 text-sm leading-tight transition-all"
|
||||
>
|
||||
</a>
|
||||
{:else}
|
||||
<p class="text-muted-foreground p-4 text-sm">{m.machine_none()}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.pages > 1}
|
||||
<div class="flex items-center justify-between gap-2 p-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => (pageNum = data.page - 1)}>{m.pagination_previous()}</Button
|
||||
>
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>{m.pagination_page_of({ page: data.page, pages: data.pages })}</span
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={data.page >= data.pages}
|
||||
onclick={() => (pageNum = data.page + 1)}>{m.pagination_next()}</Button
|
||||
>
|
||||
<span class="font-medium transition-all">{machine.name}</span>
|
||||
<span
|
||||
class="text-foreground group-hover/item:text-tertiary-foreground group-data-[active=true]/item:text-tertiary-foreground transition-all text-xs"
|
||||
>{machine.address}</span
|
||||
>
|
||||
</a>
|
||||
<div
|
||||
data-swapy-handle
|
||||
class="text-muted-foreground group-hover/item:text-tertiary-foreground group-data-[active=true]/item:text-tertiary-foreground cursor-grab p-4 px-3 flex items-center justify-center"
|
||||
>
|
||||
<GripVerticalIcon class="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:boundary>
|
||||
{:else}
|
||||
<p class="text-muted-foreground p-4 text-sm">{m.machine_none()}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if pageInfo.pages > 1}
|
||||
<div class="flex items-center justify-between gap-2 p-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageInfo.page <= 1}
|
||||
onclick={() => (pageNum = pageInfo.page - 1)}>{m.pagination_previous()}</Button
|
||||
>
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>{m.pagination_page_of({ page: pageInfo.page, pages: pageInfo.pages })}</span
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageInfo.page >= pageInfo.pages}
|
||||
onclick={() => (pageNum = pageInfo.page + 1)}>{m.pagination_next()}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+134
-2
@@ -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: {
|
||||
|
||||
@@ -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')))
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
<Sidebar.Provider style="--sidebar-width: 350px;">
|
||||
{@const currentUser = await user}
|
||||
{#if currentUser}
|
||||
{@const show = currentUser && showSidebar(currentUser)}
|
||||
{#if show }
|
||||
<AppSidebar user={currentUser} />
|
||||
{/if}
|
||||
<Sidebar.Inset>
|
||||
{#if await user}
|
||||
{#if show}
|
||||
<header
|
||||
class="bg-background sticky top-0 flex shrink-0 items-center gap-2 border-b p-4 h-15 z-50"
|
||||
>
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
+121
-114
@@ -194,11 +194,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">{m.users_title()}</h1>
|
||||
<p class="text-muted-foreground text-sm">{m.users_description()}</p>
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight flex gap-2 items-center truncate">
|
||||
{m.users_title()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{m.users_description()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Popover.Root>
|
||||
@@ -214,10 +220,10 @@
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0" align="end">
|
||||
<div class="border-b p-4">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 p-4">
|
||||
<div class="grid grid-cols-2 gap-3 p-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label class="flex items-center gap-1 text-xs"
|
||||
>{m.users_filter_active()} <InfoIcon class="size-3 opacity-60" /></Label
|
||||
@@ -239,7 +245,7 @@
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t p-4">
|
||||
<div class="border-t p-2">
|
||||
<Label class="mb-2 block text-xs">{m.users_filter_date_range()}</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
@@ -268,7 +274,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-t p-4">
|
||||
<div class="flex flex-col gap-3 border-t p-2">
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<span>{m.users_filter_email_verified()}</span>
|
||||
<Checkbox
|
||||
@@ -302,7 +308,7 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t p-4">
|
||||
<div class="flex flex-col gap-2 border-t p-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
@@ -328,7 +334,7 @@
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t p-3">
|
||||
<div class="flex items-center justify-between border-t p-2">
|
||||
<Button variant="ghost" size="sm" onclick={resetFilters}
|
||||
>{m.users_filter_reset()}</Button
|
||||
>
|
||||
@@ -365,7 +371,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.users_search_placeholder()}
|
||||
value={search}
|
||||
@@ -373,113 +379,114 @@
|
||||
class="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#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)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.users_role()}</Table.Head>
|
||||
<Table.Head>{m.users_status()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if usersQuery.loading && !data.users.length}
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !data.users.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
|
||||
>{m.users_no_results()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each data.users as u (u.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{u.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{u.username ?? '—'}</Table.Cell>
|
||||
<Table.Cell>{u.email}</Table.Cell>
|
||||
<Table.Cell>{new Date(u.createdAt).toLocaleDateString()}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{u.role === 'admin' ? m.users_role_admin() : m.users_role_user()}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if u.banned}
|
||||
<Badge variant="destructive">{m.users_banned()}</Badge>
|
||||
{:else if !u.emailVerified}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
title={u.inviteExpiresAt
|
||||
? m.users_pending_expires({
|
||||
date: new Date(u.inviteExpiresAt).toLocaleString()
|
||||
})
|
||||
: m.users_pending_no_invite()}
|
||||
>
|
||||
{m.users_pending()}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_active()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(u)}
|
||||
>{m.users_edit()}</DropdownMenu.Item
|
||||
>
|
||||
{#if !u.emailVerified}
|
||||
<DropdownMenu.Item onclick={() => doResendInvite(u)}
|
||||
>{m.users_resend_invite()}</DropdownMenu.Item
|
||||
>
|
||||
{#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)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={() => toggleBan(u)}>
|
||||
{u.banned ? m.users_unban() : m.users_ban()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(u)}>
|
||||
{m.users_delete()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.users_role()}</Table.Head>
|
||||
<Table.Head>{m.users_status()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if usersQuery.loading && !data.users.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else if !data.users.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
|
||||
>{m.users_no_results()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each data.users as u (u.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{u.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{u.username ?? '—'}</Table.Cell>
|
||||
<Table.Cell>{u.email}</Table.Cell>
|
||||
<Table.Cell>{new Date(u.createdAt).toLocaleDateString()}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{u.role === 'admin' ? m.users_role_admin() : m.users_role_user()}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if u.banned}
|
||||
<Badge variant="destructive">{m.users_banned()}</Badge>
|
||||
{:else if !u.emailVerified}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
title={u.inviteExpiresAt
|
||||
? m.users_pending_expires({
|
||||
date: new Date(u.inviteExpiresAt).toLocaleString()
|
||||
})
|
||||
: m.users_pending_no_invite()}
|
||||
>
|
||||
{m.users_pending()}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_active()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(u)}
|
||||
>{m.users_edit()}</DropdownMenu.Item
|
||||
>
|
||||
{#if !u.emailVerified}
|
||||
<DropdownMenu.Item onclick={() => doResendInvite(u)}
|
||||
>{m.users_resend_invite()}</DropdownMenu.Item
|
||||
>
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={() => toggleBan(u)}>
|
||||
{u.banned ? m.users_unban() : m.users_ban()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(u)}>
|
||||
{m.users_delete()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{m.users_page_of({ page, total: totalPages })}</span
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
} from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import Trunk from '$lib/components/blocks/trunk/trunk.svelte';
|
||||
import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte';
|
||||
import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte';
|
||||
@@ -45,6 +46,7 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Spinner } from '$lib/components/ui/spinner';
|
||||
import { machineDeleteSchema, machineEditSchema } from '$lib/machines/schema';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { deleteMachine, listMachines, updateMachine } from '$lib/remotes/machines.remote';
|
||||
@@ -57,9 +59,10 @@
|
||||
} from '$lib/remotes/server.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const info = $derived(serverInfo());
|
||||
const audit = $derived(auditLog());
|
||||
const details = $derived(systemDetails());
|
||||
const machineId = $derived(page.params.machineId!)
|
||||
const info = $derived(serverInfo(machineId));
|
||||
const audit = $derived(auditLog({limit:undefined,machineId}));
|
||||
const details = $derived(systemDetails(machineId));
|
||||
const latest = $derived(latestAgentRelease());
|
||||
const intervals = $derived([
|
||||
{ label: m.dashboard_interval_second(), value: '1' },
|
||||
@@ -76,19 +79,16 @@
|
||||
const intervalLabel = $derived(intervals.find((i) => 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}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
|
||||
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$agent_version} | {sys.$db.address}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
|
||||
@@ -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) })}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
|
||||
<div class="col-span-2">
|
||||
<div class="grid items-center gap-4 justify-center grid-cols-1 lg:grid-cols-3 p-4 py-2">
|
||||
<div class="col-span-2 w-full">
|
||||
<StoragePanel items={sys.disks ?? []} />
|
||||
</div>
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-4 lg:grid-cols-3 p-4 py-2">
|
||||
<div class="grid items-start gap-4 xl:grid-cols-3 p-4 py-2">
|
||||
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
|
||||
<NetworkPanel items={sys.network_interfaces ?? []} />
|
||||
<TemperaturePanel items={sys.temperatures ?? []} />
|
||||
@@ -518,6 +518,9 @@
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
{#snippet pending()}
|
||||
<Spinner class="size-24 m-auto"/>
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import type { Pathname } from '$app/types';
|
||||
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import PowerIcon from '@lucide/svelte/icons/power';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import {resolve} from '$app/paths'
|
||||
import { page } from '$app/state';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
const base = $derived(`/dashboard/${page.params.machineId}/system`);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system()}</h1>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a href={resolve(`${base}/date-time` as Pathname)} class="block">
|
||||
<Card.Root class="hover:bg-accent/40 h-full transition">
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<ClockIcon class="size-5" />
|
||||
<Card.Title>{m.nav_system_datetime()}</Card.Title>
|
||||
</div>
|
||||
<Card.Description>{m.nav_system_datetime_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
<a href={resolve(`${base}/hostname` as Pathname)} class="block">
|
||||
<Card.Root class="hover:bg-accent/40 h-full transition">
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<ServerIcon class="size-5" />
|
||||
<Card.Title>{m.nav_system_hostname()}</Card.Title>
|
||||
</div>
|
||||
<Card.Description>{m.nav_system_hostname_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
<a href={resolve(`${base}/localization` as Pathname)} class="block">
|
||||
<Card.Root class="hover:bg-accent/40 h-full transition">
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<GlobeIcon class="size-5" />
|
||||
<Card.Title>{m.nav_system_localization()}</Card.Title>
|
||||
</div>
|
||||
<Card.Description>{m.nav_system_localization_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
<a href={resolve(`${base}/power` as Pathname)} class="block">
|
||||
<Card.Root class="hover:bg-accent/40 h-full transition">
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<PowerIcon class="size-5" />
|
||||
<Card.Title>{m.nav_system_power()}</Card.Title>
|
||||
</div>
|
||||
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Command from '$lib/components/ui/command';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
listTimezones,
|
||||
setNtp,
|
||||
setTime,
|
||||
setTimezone,
|
||||
systemTime
|
||||
} from '$lib/remotes/system.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
|
||||
const time = $derived(systemTime(machineId));
|
||||
const tzs = $derived(listTimezones(machineId));
|
||||
const formId = $props.id();
|
||||
|
||||
let tzOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
// ponytail: builds an RFC3339 UTC string from a datetime-local value (treated as local).
|
||||
function rfc3339FromLocal(v: string) {
|
||||
return new Date(v).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
async function withSaving<T>(fn: () => Promise<T>) {
|
||||
saving = true;
|
||||
try {
|
||||
await fn();
|
||||
toast.success(m.saved());
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || 'Error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_datetime()}</h1>
|
||||
|
||||
<svelte:boundary>
|
||||
{#snippet failed(err)}
|
||||
<Card.Root>
|
||||
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
|
||||
</Card.Root>
|
||||
{/snippet}
|
||||
{@const t = await time}
|
||||
{@const zones = await tzs}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_current()}</Card.Title>
|
||||
<Card.Description>{t.time}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_timezone()}</Card.Title>
|
||||
<Card.Description>{t.timezone}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Popover.Root bind:open={tzOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tzOpen}
|
||||
class="w-full justify-between sm:w-80"
|
||||
{...props}
|
||||
>
|
||||
{t.timezone}
|
||||
<ChevronsUpDownIcon class="size-4 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder={m.system_time_search_timezone_placeholder()} />
|
||||
<Command.List class="max-h-72">
|
||||
<Command.Empty>{m.system_time_no_timezone_found()}</Command.Empty>
|
||||
{#each zones as z (z)}
|
||||
<Command.Item
|
||||
value={z}
|
||||
onSelect={async () => {
|
||||
tzOpen = false;
|
||||
if (z === t.timezone) return;
|
||||
await withSaving(() => setTimezone({machineId,timezone:z}));
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
class={'mr-2 size-4 ' + (z === t.timezone ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{z}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_ntp()}</Card.Title>
|
||||
<Card.Description>{m.system_time_ntp_hint()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex items-center justify-between gap-4">
|
||||
<div class="text-muted-foreground text-sm">
|
||||
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
|
||||
</div>
|
||||
<Switch
|
||||
checked={t.ntp}
|
||||
disabled={saving || !t.can_ntp}
|
||||
onCheckedChange={(v) => withSaving(() => setNtp({enabled:v, machineId}))}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_manual()}</Card.Title>
|
||||
<Card.Description>{m.system_time_manual_hint()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||
onsubmit={async (e) => {
|
||||
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)}));
|
||||
}}
|
||||
>
|
||||
<div class="flex grow flex-col gap-1.5">
|
||||
<Label for={formId + 'time'}>{m.system_time_current()}</Label>
|
||||
<Input
|
||||
id={formId + 'time'}
|
||||
name="time"
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
disabled={t.ntp}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={t.ntp || saving}>{m.save()}</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
|
||||
const host = $derived(systemHostname(machineId));
|
||||
const formId = $props.id();
|
||||
let saving = $state(false);
|
||||
|
||||
// ponytail: hostname syntax is RFC1123 — letters, digits, hyphen, max 63 chars per label.
|
||||
const HOSTNAME_RE = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_hostname()}</h1>
|
||||
|
||||
<svelte:boundary>
|
||||
{#snippet failed(err)}
|
||||
<Card.Root>
|
||||
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
|
||||
</Card.Root>
|
||||
{/snippet}
|
||||
{@const h = await host}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_hostname_current()}</Card.Title>
|
||||
<Card.Description>{h.hostname}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||
onsubmit={async (e) => {
|
||||
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;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex grow flex-col gap-1.5">
|
||||
<Label for={formId + 'hn'}>{m.nav_system_hostname()}</Label>
|
||||
<Input
|
||||
id={formId + 'hn'}
|
||||
name="hostname"
|
||||
value={h.hostname}
|
||||
required
|
||||
pattern="[A-Za-z0-9.-]+"
|
||||
maxlength={253}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>{m.save()}</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { page } from '$app/state';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Command from '$lib/components/ui/command';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
generateLocale,
|
||||
listKeymaps,
|
||||
listLocales,
|
||||
setKeymap,
|
||||
setLocale,
|
||||
systemLocale
|
||||
} from '$lib/remotes/system.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
|
||||
const locale = $derived(systemLocale(machineId));
|
||||
const locales = $derived(listLocales(machineId));
|
||||
const keymaps = $derived(listKeymaps(machineId));
|
||||
|
||||
let open = $state(false);
|
||||
let kmOpen = $state(false);
|
||||
let langOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let newLocale = $state('');
|
||||
let langList = $state<string[]>([]);
|
||||
let serverLanguage = $state('');
|
||||
|
||||
$effect(() => {
|
||||
locale.then((data) => {
|
||||
if (data && data.language !== serverLanguage) {
|
||||
serverLanguage = data.language || '';
|
||||
langList = serverLanguage ? serverLanguage.split(':').filter(Boolean) : [];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const languageDirty = $derived(langList.join(':') !== serverLanguage);
|
||||
|
||||
// glibc locale: language[_TERRITORY][.codeset][@modifier] — require codeset for clarity.
|
||||
const LOCALE_RE = /^[a-z]{2,3}_[A-Z]{2}\.[A-Za-z0-9-]+(@[A-Za-z0-9]+)?$/;
|
||||
const localeValid = $derived(LOCALE_RE.test(newLocale.trim()));
|
||||
|
||||
async function pick(fn: () => Promise<unknown>) {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
await fn();
|
||||
toast.success(m.saved());
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || 'Error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetLanguage() {
|
||||
const l = await locale;
|
||||
await setLocale({ lang: l.lang,language: langList.join(':'), machineId });
|
||||
serverLanguage = '';
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
const loc = newLocale.trim();
|
||||
if (!LOCALE_RE.test(loc)) {
|
||||
toast.error(m.system_locale_generate_invalid());
|
||||
return;
|
||||
}
|
||||
await generateLocale({locale:loc,machineId});
|
||||
newLocale = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_localization()}</h1>
|
||||
|
||||
<svelte:boundary>
|
||||
{#snippet failed(err)}
|
||||
<Card.Root>
|
||||
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
|
||||
</Card.Root>
|
||||
{/snippet}
|
||||
{@const l = await locale}
|
||||
{@const list = await locales}
|
||||
{@const kms = await keymaps}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_locale_lang()}</Card.Title>
|
||||
<Card.Description>{l.lang}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
class="w-full justify-between sm:w-80"
|
||||
{...props}
|
||||
>
|
||||
{l.lang}
|
||||
<ChevronsUpDownIcon class="size-4 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder={m.system_locale_search_locale_placeholder()} />
|
||||
<Command.List class="max-h-72">
|
||||
<Command.Empty>{m.system_locale_no_locale_found()}</Command.Empty>
|
||||
{#each list as loc (loc)}
|
||||
<Command.Item
|
||||
value={loc}
|
||||
onSelect={() => {
|
||||
open = false;
|
||||
if (loc !== l.lang)
|
||||
pick(() => setLocale({ lang: loc, language: l.language || undefined,machineId }));
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
class={'mr-2 size-4 ' + (loc === l.lang ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{loc}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_locale_language()}</Card.Title>
|
||||
<Card.Description>{m.system_locale_language_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#each langList as loc, i (loc + i)}
|
||||
<Badge variant="secondary" class="gap-1 pe-1">
|
||||
{loc}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted ms-0.5 rounded-sm p-0.5"
|
||||
aria-label="Remove"
|
||||
disabled={saving}
|
||||
onclick={() => (langList = langList.filter((_, j) => j !== i))}
|
||||
>
|
||||
<XIcon class="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm">{m.system_locale_language_empty()}</span>
|
||||
{/each}
|
||||
|
||||
<Popover.Root bind:open={langOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" size="sm" disabled={saving} {...props}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.system_locale_language_add()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder={m.system_locale_search_locale_placeholder()} />
|
||||
<Command.List class="max-h-72">
|
||||
<Command.Empty>{m.system_locale_no_locale_found()}</Command.Empty>
|
||||
{#each list as loc (loc)}
|
||||
<Command.Item
|
||||
value={loc}
|
||||
onSelect={() => {
|
||||
langOpen = false;
|
||||
if (!langList.includes(loc)) langList = [...langList, loc];
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
class={'mr-2 size-4 ' +
|
||||
(langList.includes(loc) ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{loc}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={saving || !languageDirty}
|
||||
onclick={() => pick(handleSetLanguage)}
|
||||
>
|
||||
{m.system_locale_language_button()}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_locale_keymap()}</Card.Title>
|
||||
<Card.Description>{l.vc_keymap || '—'}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if kms.reason && kms.keymaps.length === 0}
|
||||
<p class="text-muted-foreground text-sm">{kms.reason}</p>
|
||||
{:else}
|
||||
<Popover.Root bind:open={kmOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={kmOpen}
|
||||
class="w-full justify-between sm:w-80"
|
||||
{...props}
|
||||
>
|
||||
{l.vc_keymap || '—'}
|
||||
<ChevronsUpDownIcon class="size-4 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder={m.system_locale_search_keymap_placeholder()} />
|
||||
<Command.List class="max-h-72">
|
||||
<Command.Empty>{m.system_locale_no_keymap_found()}</Command.Empty>
|
||||
{#each kms.keymaps as km (km)}
|
||||
<Command.Item
|
||||
value={km}
|
||||
onSelect={() => {
|
||||
kmOpen = false;
|
||||
if (km !== l.vc_keymap) pick(() => setKeymap({keymap:km,machineId}));
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
class={'mr-2 size-4 ' + (km === l.vc_keymap ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{km}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_locale_x11()}</Card.Title>
|
||||
<Card.Description>{l.x11_layout || '—'}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_locale_generate()}</Card.Title>
|
||||
<Card.Description>{m.system_locale_generate_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex flex-col sm:flex-row gap-3 max-w-md">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={m.system_locale_generate_placeholder()}
|
||||
bind:value={newLocale}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={saving || !localeValid}
|
||||
onclick={() => pick(handleGenerate)}
|
||||
>
|
||||
{m.system_locale_generate_button()}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import PowerIcon from '@lucide/svelte/icons/power';
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||
import { page } from '$app/state';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { powerOff, reboot } from '$lib/remotes/system.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let pending = $state<'off' | 'reboot' | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
const machineId = $derived(page.params.machineId!)
|
||||
async function run(action: 'off' | 'reboot') {
|
||||
busy = true;
|
||||
try {
|
||||
await (action === 'off' ? powerOff({machineId,when:''}) : reboot({machineId,when:''}));
|
||||
toast.success(m.saved());
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || 'Error');
|
||||
} finally {
|
||||
busy = false;
|
||||
pending = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_power()}</h1>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.nav_system_power()}</Card.Title>
|
||||
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={busy} onclick={() => (pending = 'reboot')}>
|
||||
<RotateCcwIcon class="size-4" />
|
||||
{m.system_power_reboot()}
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={busy} onclick={() => (pending = 'off')}>
|
||||
<PowerIcon class="size-4" />
|
||||
{m.system_power_poweroff()}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Root
|
||||
open={pending !== null}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) pending = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
{pending === 'off' ? m.system_power_confirm_poweroff_title() : m.system_power_confirm_reboot_title()}
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>{m.system_power_confirm_description()}</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel disabled={busy}>{m.cancel()}</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
disabled={busy}
|
||||
class={buttonVariants({ variant: 'destructive' })}
|
||||
onclick={() => pending && run(pending)}
|
||||
>
|
||||
{pending === 'off' ? m.system_power_poweroff() : m.system_power_reboot()}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,512 @@
|
||||
<script lang="ts">
|
||||
import type { Pathname } from '$app/types';
|
||||
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page as pageState } from '$app/state';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
createPamUser,
|
||||
deletePamUser,
|
||||
listPamUsers,
|
||||
setPamUserPassword
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type PamUser = {
|
||||
comment: string;
|
||||
gid: number;
|
||||
home: string;
|
||||
shell: string;
|
||||
system: boolean;
|
||||
uid: number;
|
||||
username: string;
|
||||
};
|
||||
type SortBy = 'shell' | 'uid' | 'username';
|
||||
type Dir = 'asc' | 'desc';
|
||||
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const users = $derived(listPamUsers(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('pam.users.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
|
||||
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('pam.users.sort', {
|
||||
sortBy: 'uid',
|
||||
sortDir: 'asc'
|
||||
});
|
||||
let sortBy = $state<SortBy>(sortStore.current.sortBy);
|
||||
let sortDir = $state<Dir>(sortStore.current.sortDir);
|
||||
$effect(() => {
|
||||
sortStore.current = { sortBy, sortDir };
|
||||
});
|
||||
|
||||
const filtersStore = new PersistedState('pam.users.filters', {
|
||||
shellOnly: false,
|
||||
showSystem: false
|
||||
});
|
||||
let filters = $state({ ...filtersStore.current });
|
||||
$effect(() => {
|
||||
filtersStore.current = filters;
|
||||
});
|
||||
const activeFilterCount = $derived(
|
||||
(filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0)
|
||||
);
|
||||
|
||||
function onSearchInput(e: Event) {
|
||||
search = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
page = 1;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function toggleSort(col: SortBy) {
|
||||
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
sortBy = col;
|
||||
sortDir = 'asc';
|
||||
}
|
||||
page = 1;
|
||||
}
|
||||
|
||||
const NOLOGIN = /(nologin|false)$/;
|
||||
const filtered = $derived.by(() => {
|
||||
const all = (users.current ?? []) as PamUser[];
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
const out = all.filter((u) => {
|
||||
if (!filters.showSystem && u.system) return false;
|
||||
if (filters.shellOnly && NOLOGIN.test(u.shell)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
u.comment.toLowerCase().includes(q) ||
|
||||
String(u.uid).includes(q)
|
||||
);
|
||||
});
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
out.sort((a, b) => {
|
||||
const av = a[sortBy] as number | string;
|
||||
const bv = b[sortBy] as number | string;
|
||||
if (av < bv) return -1 * dir;
|
||||
if (av > bv) return 1 * dir;
|
||||
return 0;
|
||||
});
|
||||
return out;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(
|
||||
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
|
||||
);
|
||||
|
||||
// Dialogs
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({
|
||||
comment: '',
|
||||
create_home: true,
|
||||
shell: '/bin/bash',
|
||||
system: false,
|
||||
username: ''
|
||||
});
|
||||
let pwOpen = $state(false);
|
||||
let pwUser = $state<null | PamUser>(null);
|
||||
let pwValue = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state<null | PamUser>(null);
|
||||
let removeHome = $state(false);
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
try {
|
||||
await createPamUser({
|
||||
comment: createForm.comment || undefined,
|
||||
create_home: createForm.create_home,
|
||||
machineId,
|
||||
shell: createForm.shell || undefined,
|
||||
system: createForm.system,
|
||||
username: createForm.username.trim()
|
||||
});
|
||||
toast.success(m.users_created());
|
||||
createOpen = false;
|
||||
createForm = { comment: '', create_home: true, shell: '/bin/bash', system: false, username: '' };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await deletePamUser({
|
||||
machineId,
|
||||
remove_home: removeHome, username: deleting.username });
|
||||
toast.success(m.users_deleted());
|
||||
deleteOpen = false;
|
||||
deleting = null;
|
||||
removeHome = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doSetPassword() {
|
||||
if (!pwUser || !pwValue) return;
|
||||
try {
|
||||
await setPamUserPassword({
|
||||
machineId,
|
||||
password: pwValue, username: pwUser.username });
|
||||
toast.success(m.saved());
|
||||
pwOpen = false;
|
||||
pwUser = null;
|
||||
pwValue = '';
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.users_nav_title()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{m.users_nav_description()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
{#if activeFilterCount > 0}
|
||||
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span>{m.users_filter_show_system()}</span>
|
||||
<small>{m.users_filter_system_uid_hint()}</small>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={filters.showSystem}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showSystem = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<span>{m.users_filter_shell_only()}</span>
|
||||
<Checkbox
|
||||
checked={filters.shellOnly}
|
||||
onCheckedChange={(v) => {
|
||||
filters.shellOnly = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t p-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="pam-rpp-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="pam-rpp-{id}">
|
||||
{#each [10, 25, 50, 100, 200] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.users_add()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.users_pam_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onSearchInput}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {users.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: 'UID' }, { key: 'shell', label: 'Shell' }] as col (col.key)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.users_col_comment()}</Table.Head>
|
||||
<Table.Head>{m.users_col_home()}</Table.Head>
|
||||
<Table.Head>{m.users_col_type()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if users.loading && !users.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !pageRows.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
|
||||
>{m.users_no_results()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as u (u.username)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href={resolve(`/dashboard/${pageState.params.machineId}/users/${u.username}` as Pathname)}
|
||||
class="hover:underline">{u.username}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
|
||||
<Table.Cell>{u.comment || '—'}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if u.system}
|
||||
<Badge variant="secondary">{m.users_type_system()}</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_type_user()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
pwUser = u;
|
||||
pwValue = '';
|
||||
pwOpen = true;
|
||||
}}>{m.users_action_set_password()}</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = u;
|
||||
removeHome = false;
|
||||
deleteOpen = true;
|
||||
}}>{m.users_delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{m.users_page_of({ page, total: totalPages })}</span
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.users_prev()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onclick={() => (page += 1)}
|
||||
>
|
||||
{m.users_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.users_create_title()}</Dialog.Title>
|
||||
<Dialog.Description>{m.users_pam_create_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doCreate();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cu-username-{id}">{m.username()}</Label>
|
||||
<Input
|
||||
id="cu-username-{id}"
|
||||
bind:value={createForm.username}
|
||||
required
|
||||
pattern="[a-z_][a-z0-9_-]*"
|
||||
maxlength={32}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cu-comment-{id}">{m.users_create_field_comment()}</Label>
|
||||
<Input id="cu-comment-{id}" bind:value={createForm.comment} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cu-shell-{id}">{m.users_create_field_shell()}</Label>
|
||||
<Input id="cu-shell-{id}" bind:value={createForm.shell} />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={createForm.create_home}
|
||||
onCheckedChange={(v) => (createForm.create_home = !!v)}
|
||||
/>
|
||||
{m.users_create_field_create_home()}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={createForm.system}
|
||||
onCheckedChange={(v) => (createForm.system = !!v)}
|
||||
/>
|
||||
{m.users_create_field_system()}
|
||||
</label>
|
||||
<Dialog.Footer class="mt-2">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit">{m.users_create()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={pwOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.users_set_password_title({ username: pwUser?.username ?? '' })}</Dialog.Title>
|
||||
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSetPassword();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="pw-{id}">{m.password()}</Label>
|
||||
<Input id="pw-{id}" type="password" bind:value={pwValue} required minlength={1} />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit" disabled={!pwValue}>{m.save()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={deleteOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>{m.users_delete_title({ username: deleting?.username ?? '' })}</AlertDialog.Title>
|
||||
<AlertDialog.Description
|
||||
>{m.users_delete_description()}</AlertDialog.Description
|
||||
>
|
||||
</AlertDialog.Header>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox bind:checked={removeHome} />
|
||||
{m.users_delete_field_remove_home()}
|
||||
</label>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
import type { Pathname } from '$app/types';
|
||||
|
||||
import KeyIcon from '@lucide/svelte/icons/key';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page as pageState } from '$app/state';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
deletePamUser,
|
||||
getPamUser,
|
||||
listPamGroups,
|
||||
setPamUserGroups,
|
||||
setPamUserPassword
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
const id = $props.id();
|
||||
const username = $derived(pageState.params.username!);
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const user = $derived(getPamUser({machineId,username,}));
|
||||
const groups = $derived(listPamGroups(machineId));
|
||||
|
||||
const primary = $derived(
|
||||
(groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1))
|
||||
);
|
||||
const supplementary = $derived(
|
||||
(groups.current ?? []).filter((g) => g.members?.includes(username)).map((g) => g.name)
|
||||
);
|
||||
|
||||
let editing = $state(false);
|
||||
const selected = new SvelteSet<string>()
|
||||
$effect(() => {
|
||||
if (!editing) {selected.clear(); selected.union(new Set(supplementary))}
|
||||
});
|
||||
const dirty = $derived.by(() => {
|
||||
const cur = new Set(supplementary);
|
||||
if (cur.size !== selected.size) return true;
|
||||
for (const x of cur) if (!selected.has(x)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
let pwOpen = $state(false);
|
||||
let pwValue = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
let removeHome = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
}
|
||||
|
||||
function toggle(name: string, on: boolean) {
|
||||
const next = new SvelteSet(selected);
|
||||
if (on) next.add(name);
|
||||
else next.delete(name);
|
||||
selected.clear()
|
||||
selected.union(next)
|
||||
}
|
||||
|
||||
async function saveGroups() {
|
||||
saving = true;
|
||||
try {
|
||||
await setPamUserGroups({ groups: [...selected], machineId, username });
|
||||
toast.success(m.users_groups_updated());
|
||||
editing = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doSetPassword() {
|
||||
if (!pwValue) return;
|
||||
try {
|
||||
await setPamUserPassword({ machineId,password: pwValue, username });
|
||||
toast.success(m.saved());
|
||||
pwOpen = false;
|
||||
pwValue = '';
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
try {
|
||||
await deletePamUser({ machineId,remove_home: removeHome, username });
|
||||
toast.success(m.users_deleted());
|
||||
await goto(resolve(`/dashboard/${machineId}/users` as Pathname));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<svelte:boundary>
|
||||
{#snippet failed(err)}
|
||||
<Card.Root>
|
||||
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
|
||||
</Card.Root>
|
||||
{/snippet}
|
||||
{@const u = await user}
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
{u.username}
|
||||
{#if u.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
|
||||
>{m.users_type_user()}</Badge
|
||||
>{/if}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm">{u.comment || m.users_no_gecos()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" onclick={() => (pwOpen = true)}>
|
||||
<KeyIcon class="size-4" />
|
||||
{m.users_action_set_password()}
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
|
||||
<Trash2Icon class="size-4" />
|
||||
{m.users_delete()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.users_details()}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-muted-foreground">UID</dt>
|
||||
<dd class="font-mono">{u.uid}</dd>
|
||||
<dt class="text-muted-foreground">{m.users_primary_gid()}</dt>
|
||||
<dd class="font-mono">
|
||||
{u.gid}{#if primary}
|
||||
<span class="text-muted-foreground"> ({primary.name})</span>
|
||||
{/if}
|
||||
</dd>
|
||||
<dt class="text-muted-foreground">{m.users_col_home()}</dt>
|
||||
<dd class="font-mono">{u.home}</dd>
|
||||
<dt class="text-muted-foreground">{m.users_create_field_shell()}</dt>
|
||||
<dd class="font-mono">{u.shell}</dd>
|
||||
</dl>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>{m.users_groups_title()}</Card.Title>
|
||||
<Card.Description>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html m.users_pam_groups_description()}
|
||||
</Card.Description>
|
||||
</div>
|
||||
{#if !editing}
|
||||
<Button variant="outline" onclick={() => (editing = true)}>{m.edit()}</Button>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
editing = false;
|
||||
selected.clear()
|
||||
selected.union(new Set(supplementary))
|
||||
}}>{m.cancel()}</Button
|
||||
>
|
||||
<Button onclick={saveGroups} disabled={saving || !dirty}>{m.save()}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if !editing}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#if primary}
|
||||
<Badge variant="default" title="Primary group">{primary.name} {m.users_group_primary_badge()}</Badge>
|
||||
{/if}
|
||||
{#each supplementary as name (name)}
|
||||
<Badge variant="outline">{name}</Badge>
|
||||
{:else}
|
||||
{#if !primary}
|
||||
<span class="text-muted-foreground text-sm">{m.users_no_groups()}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="max-h-80 overflow-auto rounded-md border">
|
||||
<div class="grid grid-cols-2 gap-1 p-2 sm:grid-cols-3">
|
||||
{#each (groups.current ?? []).filter((g) => g.gid !== u.gid) as g (g.name)}
|
||||
<label class="flex items-center gap-2 rounded p-1.5 text-sm hover:bg-muted">
|
||||
<Checkbox
|
||||
checked={selected.has(g.name)}
|
||||
onCheckedChange={(v) => toggle(g.name, !!v)}
|
||||
/>
|
||||
<span class="truncate" title={g.name}>{g.name}</span>
|
||||
{#if g.system}
|
||||
<span class="text-muted-foreground text-xs">{m.users_group_sys_badge()}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={pwOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.users_set_password_title({ username })}</Dialog.Title>
|
||||
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSetPassword();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="pw-{id}">{m.password()}</Label>
|
||||
<Input id="pw-{id}" type="password" bind:value={pwValue} required minlength={1} />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit" disabled={!pwValue}>{m.save()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={deleteOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>{m.users_delete_title({ username })}</AlertDialog.Title>
|
||||
<AlertDialog.Description>{m.users_delete_description()}</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox bind:checked={removeHome} />
|
||||
{m.users_delete_field_remove_home()}
|
||||
</label>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,400 @@
|
||||
<script lang="ts">
|
||||
import type { Pathname } from '$app/types';
|
||||
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { resolve } from '$app/paths';
|
||||
import {page as pageState} from '$app/state'
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { createPamGroup, deletePamGroup, listPamGroups } from '$lib/remotes/pam-users.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { toast } from 'svelte-sonner';
|
||||
type PamGroup = {
|
||||
gid: number;
|
||||
members: null | string[];
|
||||
name: string;
|
||||
system: boolean;
|
||||
};
|
||||
type SortBy = 'gid' | 'members' | 'name';
|
||||
type Dir = 'asc' | 'desc';
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!)
|
||||
const groups = $derived(listPamGroups(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('pam.groups.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
|
||||
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('pam.groups.sort', {
|
||||
sortBy: 'gid',
|
||||
sortDir: 'asc'
|
||||
});
|
||||
let sortBy = $state<SortBy>(sortStore.current.sortBy);
|
||||
let sortDir = $state<Dir>(sortStore.current.sortDir);
|
||||
$effect(() => {
|
||||
sortStore.current = { sortBy, sortDir };
|
||||
});
|
||||
|
||||
const filtersStore = new PersistedState('pam.groups.filters', { showSystem: false });
|
||||
let filters = $state({ ...filtersStore.current });
|
||||
$effect(() => {
|
||||
filtersStore.current = filters;
|
||||
});
|
||||
const activeFilterCount = $derived(filters.showSystem ? 1 : 0);
|
||||
|
||||
function onSearchInput(e: Event) {
|
||||
search = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
page = 1;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function toggleSort(col: SortBy) {
|
||||
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
sortBy = col;
|
||||
sortDir = 'asc';
|
||||
}
|
||||
page = 1;
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const all = (groups.current ?? []) as PamGroup[];
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
const out = all.filter((g) => {
|
||||
if (!filters.showSystem && g.system) return false;
|
||||
if (!q) return true;
|
||||
return g.name.toLowerCase().includes(q) || String(g.gid).includes(q);
|
||||
});
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
out.sort((a, b) => {
|
||||
const av = sortBy === 'members' ? (a.members?.length ?? 0) : (a[sortBy] as number | string);
|
||||
const bv = sortBy === 'members' ? (b.members?.length ?? 0) : (b[sortBy] as number | string);
|
||||
if (av < bv) return -1 * dir;
|
||||
if (av > bv) return 1 * dir;
|
||||
return 0;
|
||||
});
|
||||
return out;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(
|
||||
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
|
||||
);
|
||||
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({ gid: '', name: '', system: false });
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state<null | PamGroup>(null);
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
try {
|
||||
await createPamGroup({
|
||||
gid: createForm.gid ? Number(createForm.gid) : undefined,
|
||||
machineId,
|
||||
name: createForm.name.trim(),
|
||||
system: createForm.system
|
||||
});
|
||||
toast.success(m.groups_created());
|
||||
createOpen = false;
|
||||
createForm = { gid: '', name: '', system: false };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await deletePamGroup({group:deleting.name, machineId});
|
||||
toast.success(m.groups_deleted());
|
||||
deleteOpen = false;
|
||||
deleting = null;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.groups_nav_title()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{m.groups_nav_description()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
{#if activeFilterCount > 0}
|
||||
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span>{m.groups_filter_show_system()}</span>
|
||||
<small>{m.groups_filter_system_gid_hint()}</small>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
checked={filters.showSystem}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showSystem = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t p-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.groups_add()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.groups_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onSearchInput}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {groups.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: 'GID' }, { key: 'members', label: 'Members' }] as col (col.key)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if groups.loading && !groups.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !pageRows.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
|
||||
>{m.groups_no_results()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as g (g.name)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href={resolve(`/dashboard/${pageState.params.machineId}/users/groups/${g.name}` as Pathname)}
|
||||
class="hover:underline">{g.name}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{#if g.members?.length}
|
||||
<span title={g.members.join(', ')}>
|
||||
{g.members.slice(0, 3).join(', ')}{g.members.length > 3
|
||||
? ` +${g.members.length - 3}`
|
||||
: ''}
|
||||
</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if g.system}
|
||||
<Badge variant="secondary">{m.users_type_system()}</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_type_user()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = g;
|
||||
deleteOpen = true;
|
||||
}}>{m.users_delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{m.users_page_of({ page, total: totalPages })}</span
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.users_prev()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onclick={() => (page += 1)}
|
||||
>
|
||||
{m.users_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.groups_create_title()}</Dialog.Title>
|
||||
<Dialog.Description>{m.groups_create_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doCreate();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cg-name-{id}">{m.name()}</Label>
|
||||
<Input
|
||||
id="cg-name-{id}"
|
||||
bind:value={createForm.name}
|
||||
required
|
||||
pattern="[a-z_][a-z0-9_-]*"
|
||||
maxlength={32}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cg-gid-{id}">GID (optional)</Label>
|
||||
<Input id="cg-gid-{id}" type="number" min="0" bind:value={createForm.gid} />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={createForm.system}
|
||||
onCheckedChange={(v) => (createForm.system = !!v)}
|
||||
/>
|
||||
{m.groups_create_field_system()}
|
||||
</label>
|
||||
<Dialog.Footer class="mt-2">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit">{m.users_create()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={deleteOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>{m.groups_delete_title({ name: deleting?.name ?? '' })}</AlertDialog.Title>
|
||||
<AlertDialog.Description
|
||||
>{m.groups_delete_description()}</AlertDialog.Description
|
||||
>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import type { Pathname } from '$app/types';
|
||||
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page as pageState } from '$app/state';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
deletePamGroup,
|
||||
listPamGroups,
|
||||
listPamUsers,
|
||||
setPamUserGroups
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const groupName = $derived(pageState.params.group!);
|
||||
const groups = $derived(listPamGroups(machineId));
|
||||
const users = $derived(listPamUsers(machineId));
|
||||
|
||||
const group = $derived((groups.current ?? []).find((g) => g.name === groupName));
|
||||
const members = $derived(group?.members ?? []);
|
||||
const primaryMembers = $derived(
|
||||
(users.current ?? []).filter((u) => u.gid === group?.gid).map((u) => u.username)
|
||||
);
|
||||
|
||||
const candidates = $derived(
|
||||
(users.current ?? [])
|
||||
.map((u) => u.username)
|
||||
.filter((n) => !members.includes(n) && !primaryMembers.includes(n))
|
||||
);
|
||||
|
||||
function supplementaryOf(username: string): string[] {
|
||||
return (groups.current ?? [])
|
||||
.filter((g) => g.members?.includes(username))
|
||||
.map((g) => g.name);
|
||||
}
|
||||
|
||||
let addOpen = $state(false);
|
||||
let addQuery = $state('');
|
||||
let busy = $state<null | string>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
}
|
||||
|
||||
async function addMember(username: string) {
|
||||
busy = username;
|
||||
try {
|
||||
const next = [...new Set([...supplementaryOf(username), groupName])];
|
||||
await setPamUserGroups({ groups: next,machineId, username });
|
||||
toast.success(m.groups_member_added({ username }));
|
||||
addOpen = false;
|
||||
addQuery = '';
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(username: string) {
|
||||
busy = username;
|
||||
try {
|
||||
const next = supplementaryOf(username).filter((g) => g !== groupName);
|
||||
await setPamUserGroups({ groups: next,machineId, username });
|
||||
toast.success(m.groups_member_removed({ username }));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
try {
|
||||
await deletePamGroup({group:groupName,machineId,});
|
||||
toast.success(m.groups_deleted());
|
||||
await goto(resolve(`/dashboard/${machineId}/users/groups` as Pathname));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredCandidates = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
return q ? candidates.filter((n) => n.toLowerCase().includes(q)) : candidates;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
{#if groups.loading && !groups.current}
|
||||
<Card.Root>
|
||||
<Card.Content class="text-muted-foreground py-6">…</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if !group}
|
||||
<Card.Root>
|
||||
<Card.Content class="text-destructive py-6">{m.groups_not_found({ name: groupName })}</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
{group.name}
|
||||
{#if group.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
|
||||
>{m.users_type_user()}</Badge
|
||||
>{/if}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm font-mono">gid {group.gid}</p>
|
||||
</div>
|
||||
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
|
||||
<Trash2Icon class="size-4" />
|
||||
{m.users_delete()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>{m.groups_members_title()}</Card.Title>
|
||||
<Card.Description>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html m.groups_members_description({ gid: group.gid })}
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (addOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.groups_add_member()}
|
||||
</Button>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs">{m.groups_supplementary_label({ count: members.length })}</Label>
|
||||
{#if members.length}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each members as name (name)}
|
||||
<Badge variant="outline" class="gap-1 pe-1">
|
||||
<a
|
||||
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
|
||||
class="hover:underline">{name}</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted rounded-sm p-0.5 disabled:opacity-50"
|
||||
disabled={busy === name}
|
||||
onclick={() => removeMember(name)}
|
||||
aria-label="Remove {name}"
|
||||
>
|
||||
<XIcon class="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm">{m.groups_no_supplementary_members()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs">{m.groups_primary_label({ count: primaryMembers.length })}</Label>
|
||||
{#if primaryMembers.length}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each primaryMembers as name (name)}
|
||||
<Badge variant="default">
|
||||
<a
|
||||
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
|
||||
class="hover:underline">{name}</a
|
||||
>
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm">{m.groups_primary_empty()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={addOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.groups_add_member_title({ name: groupName })}</Dialog.Title>
|
||||
<Dialog.Description>{m.groups_add_member_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Input
|
||||
id="add-q-{id}"
|
||||
placeholder={m.groups_add_member_search_placeholder()}
|
||||
bind:value={addQuery}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="max-h-72 overflow-auto rounded-md border">
|
||||
{#if !filteredCandidates.length}
|
||||
<div class="text-muted-foreground p-3 text-sm">{m.groups_add_member_no_results()}</div>
|
||||
{:else}
|
||||
{#each filteredCandidates as name (name)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted flex w-full items-center justify-between px-3 py-2 text-sm disabled:opacity-50"
|
||||
disabled={busy === name}
|
||||
onclick={() => addMember(name)}
|
||||
>
|
||||
<span>{name}</span>
|
||||
<span class="text-muted-foreground text-xs">{m.groups_add_member_action()}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (addOpen = false)}>{m.cancel()}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={deleteOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>{m.groups_delete_title({ name: groupName })}</AlertDialog.Title>
|
||||
<AlertDialog.Description
|
||||
>{m.groups_delete_description()}</AlertDialog.Description
|
||||
>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
Reference in New Issue
Block a user