diff --git a/runtime/hub/core/authenticators/firstuse.py b/runtime/hub/core/authenticators/firstuse.py index 6563dc9..1307214 100644 --- a/runtime/hub/core/authenticators/firstuse.py +++ b/runtime/hub/core/authenticators/firstuse.py @@ -26,6 +26,8 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor + import bcrypt from firstuseauthenticator import FirstUseAuthenticator @@ -105,6 +107,78 @@ def set_password(self, username: str, password: str, force_change: bool = True) suffix = " (force change on next login)" if force_change else "" return f"Password set for {username}{suffix}" + def batch_set_passwords( + self, + users: list[dict], + force_change: bool = True, + ) -> dict: + """Set passwords for multiple users in a single transaction. + + Args: + users: List of dicts with 'username' and 'password' keys. + force_change: Whether to force password change on first login. + + Returns: + Dict with 'success', 'failed' counts and 'results' list. + """ + min_len = getattr(self, "min_password_length", 1) + results = {"success": 0, "failed": 0, "results": []} + + # Validate passwords first + valid_entries = [] + for entry in users: + username = entry["username"] + password = entry["password"] + if not password or len(password) < min_len: + results["failed"] += 1 + results["results"].append( + { + "username": username, + "status": "failed", + "error": f"Password too short (min {min_len})", + } + ) + continue + valid_entries.append((username, password)) + + # Parallel bcrypt hashing (bcrypt releases GIL, threads give real speedup) + def _hash(pw: str) -> bytes: + return bcrypt.hashpw(pw.encode("utf8"), bcrypt.gensalt()) + + with ThreadPoolExecutor() as pool: + hash_results = list(pool.map(_hash, [pw for _, pw in valid_entries])) + hashed = [(username, h) for (username, _), h in zip(valid_entries, hash_results)] + + # Single DB transaction with per-user savepoints + with session_scope() as session: + for username, password_hash in hashed: + try: + with session.begin_nested(): + user_pw = session.query(UserPassword).filter_by(username=username).first() + if user_pw: + user_pw.password_hash = password_hash + user_pw.force_change = force_change + else: + user_pw = UserPassword( + username=username, + password_hash=password_hash, + force_change=force_change, + ) + session.add(user_pw) + results["success"] += 1 + results["results"].append({"username": username, "status": "success"}) + except Exception as e: + results["failed"] += 1 + results["results"].append( + { + "username": username, + "status": "failed", + "error": str(e), + } + ) + + return results + def mark_force_password_change(self, username: str, force: bool = True) -> None: """Mark or unmark a user for forced password change.""" with session_scope() as session: diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index 7532a26..49d1486 100644 --- a/runtime/hub/core/handlers.py +++ b/runtime/hub/core/handlers.py @@ -58,6 +58,7 @@ "quota_rates": {}, "quota_enabled": False, "minimum_quota_to_start": 10, + "default_quota": 0, } @@ -66,6 +67,7 @@ def configure_handlers( quota_rates: dict[str, int] | None = None, quota_enabled: bool = False, minimum_quota_to_start: int = 10, + default_quota: int = 0, ) -> None: """Configure handler module with runtime settings.""" if accelerator_options is not None: @@ -74,6 +76,7 @@ def configure_handlers( _handler_config["quota_rates"] = quota_rates _handler_config["quota_enabled"] = quota_enabled _handler_config["minimum_quota_to_start"] = minimum_quota_to_start + _handler_config["default_quota"] = default_quota # ============================================================================= @@ -377,6 +380,77 @@ async def get(self): self.finish(json.dumps({"password": password})) +class AdminAPIBatchSetPasswordHandler(APIHandler): + """API endpoint for batch setting user passwords.""" + + @web.authenticated + async def post(self): + """Set passwords for multiple users in a single request.""" + assert self.current_user is not None + if not self.current_user.admin: + self.set_status(403) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "Admin access required"})) + + try: + data = json.loads(self.request.body.decode("utf-8")) + users = data.get("users", []) + force_change = data.get("force_change", True) + + if not users or not isinstance(users, list): + self.set_status(400) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "users array is required"})) + + if len(users) > 1000: + self.set_status(400) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "Maximum 1000 users per batch"})) + + # Validate entries + for entry in users: + if not isinstance(entry, dict) or "username" not in entry or "password" not in entry: + self.set_status(400) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "Each entry must have username and password"})) + if entry.get("username", "").startswith("github:"): + self.set_status(400) + self.set_header("Content-Type", "application/json") + return self.finish( + json.dumps({"error": f"Cannot set password for GitHub user: {entry['username']}"}) + ) + + firstuse_auth = None + if isinstance(self.authenticator, MultiAuthenticator): + for authenticator in self.authenticator._authenticators: + if isinstance(authenticator, CustomFirstUseAuthenticator): + firstuse_auth = authenticator + break + + if not firstuse_auth: + self.set_status(500) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "Password management not available"})) + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, lambda: firstuse_auth.batch_set_passwords(users, force_change=force_change) + ) + + self.set_header("Content-Type", "application/json") + self.finish(json.dumps(result)) + + except json.JSONDecodeError: + self.set_status(400) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps({"error": "Invalid JSON"})) + except Exception as e: + self.log.error(f"Failed to batch set passwords: {e}") + self.set_status(500) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps({"error": "Internal server error"})) + + # ============================================================================= # Quota Management Handlers # ============================================================================= @@ -531,19 +605,44 @@ async def post(self): quota_manager = get_quota_manager() admin_name = self.current_user.name + # Separate unlimited and regular users + unlimited_users = [u for u in req.users if u.unlimited is True] + unset_unlimited_users = [u for u in req.users if u.unlimited is False] + regular_users = [u for u in req.users if u.unlimited is None] + results = {"success": 0, "failed": 0, "details": []} - for user in req.users: + # Handle unlimited users + for user in unlimited_users: try: - quota_manager.set_balance(user.username, user.amount, admin_name) + quota_manager.set_unlimited(user.username, True, admin_name) results["success"] += 1 - results["details"].append({"username": user.username, "status": "success", "balance": user.amount}) + results["details"].append({"username": user.username, "status": "success", "unlimited": True}) except Exception: results["failed"] += 1 results["details"].append( {"username": user.username, "status": "failed", "error": "Processing error"} ) + # Handle unset-unlimited users + for user in unset_unlimited_users: + try: + quota_manager.set_unlimited(user.username, False, admin_name) + results["success"] += 1 + except Exception: + results["failed"] += 1 + results["details"].append( + {"username": user.username, "status": "failed", "error": "Processing error"} + ) + + # Batch set balance for regular users + unset-unlimited users in single transaction + batch_users = [(u.username, u.amount) for u in regular_users + unset_unlimited_users] + if batch_users: + batch_result = quota_manager.batch_set_quota(batch_users, admin_name) + results["success"] += batch_result["success"] + results["failed"] += batch_result["failed"] + results["details"].extend(batch_result.get("details", [])) + self.set_header("Content-Type", "application/json") self.finish(json.dumps(results)) @@ -641,6 +740,7 @@ async def get(self): "enabled": _handler_config["quota_enabled"], "rates": _handler_config["quota_rates"], "minimum_to_start": _handler_config["minimum_quota_to_start"], + "default_quota": _handler_config["default_quota"], } ) ) @@ -1079,6 +1179,7 @@ def get_handlers() -> list[tuple[str, type]]: (r"/admin/users", AdminUIHandler), (r"/admin/groups", AdminUIHandler), (r"/admin/api/set-password", AdminAPISetPasswordHandler), + (r"/admin/api/batch-set-password", AdminAPIBatchSetPasswordHandler), (r"/admin/api/generate-password", AdminAPIGeneratePasswordHandler), # Accelerator info API (r"/api/accelerators", AcceleratorsAPIHandler), diff --git a/runtime/hub/core/quota/manager.py b/runtime/hub/core/quota/manager.py index 89cb429..7f08c13 100644 --- a/runtime/hub/core/quota/manager.py +++ b/runtime/hub/core/quota/manager.py @@ -476,15 +476,38 @@ def get_all_balances(self) -> list[dict]: session.close() def batch_set_quota(self, users: list[tuple[str, int]], admin: str | None = None) -> dict: - """Set quota for multiple users at once.""" - results = {"success": 0, "failed": 0} - for username, amount in users: - try: - self.set_balance(username, amount, admin) - results["success"] += 1 - except Exception as e: - results["failed"] += 1 - print(f"Failed to set quota for {username}: {e}") + """Set quota for multiple users in a single transaction with per-user savepoints.""" + results = {"success": 0, "failed": 0, "details": []} + with self._op_lock, session_scope() as session: + for username, amount in users: + try: + with session.begin_nested(): + uname = username.lower() + user = session.query(UserQuota).filter(UserQuota.username == uname).first() + if not user: + user = UserQuota(username=uname, balance=amount) + session.add(user) + balance_before = 0 + else: + balance_before = user.balance + user.balance = amount + + transaction = QuotaTransaction( + username=uname, + amount=amount - balance_before, + transaction_type="set", + balance_before=balance_before, + balance_after=amount, + description=f"Balance set to {amount}", + created_by=admin, + ) + session.add(transaction) + results["success"] += 1 + results["details"].append({"username": uname, "status": "success", "balance": amount}) + except Exception as e: + results["failed"] += 1 + results["details"].append({"username": username, "status": "failed", "error": str(e)}) + print(f"Failed to set quota for {username}: {e}") return results def _match_targets(self, username: str, balance: int, is_unlimited: bool, targets: dict) -> bool: diff --git a/runtime/hub/core/quota/models.py b/runtime/hub/core/quota/models.py index c6e7d2e..875d766 100644 --- a/runtime/hub/core/quota/models.py +++ b/runtime/hub/core/quota/models.py @@ -69,7 +69,8 @@ class BatchQuotaUser(BaseModel): """User entry for batch quota operation.""" username: str = Field(..., min_length=1, max_length=200, pattern=r"^[a-zA-Z0-9._@-]+$") - amount: int = Field(..., ge=-10_000_000, le=10_000_000) + amount: int = Field(default=0, ge=-10_000_000, le=10_000_000) + unlimited: bool | None = Field(default=None, description="Set unlimited quota (overrides amount)") class BatchQuotaRequest(BaseModel): diff --git a/runtime/hub/core/setup.py b/runtime/hub/core/setup.py index 43b8a88..849af3d 100644 --- a/runtime/hub/core/setup.py +++ b/runtime/hub/core/setup.py @@ -126,6 +126,7 @@ async def auth_state_hook(spawner, auth_state): quota_rates=config.build_quota_rates(), quota_enabled=config.quota_enabled, minimum_quota_to_start=config.quota.minimumToStart, + default_quota=config.quota.defaultQuota, ) if not hasattr(c.JupyterHub, "extra_handlers") or c.JupyterHub.extra_handlers is None: diff --git a/runtime/hub/frontend/apps/admin/src/components/BatchPasswordModal.tsx b/runtime/hub/frontend/apps/admin/src/components/BatchPasswordModal.tsx new file mode 100644 index 0000000..46fcf43 --- /dev/null +++ b/runtime/hub/frontend/apps/admin/src/components/BatchPasswordModal.tsx @@ -0,0 +1,300 @@ +// Copyright (C) 2025 Advanced Micro Devices, Inc. All rights reserved. +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useState, useMemo } from 'react'; +import { Modal, Button, Form, Alert, Spinner, InputGroup, Badge } from 'react-bootstrap'; +import * as api from '@auplc/shared'; + +interface Props { + show: boolean; + usernames: string[]; + onHide: () => void; +} + +interface PasswordResult { + username: string; + password: string; + status: 'success' | 'failed'; + error?: string; +} + +export function BatchPasswordModal({ show, usernames, onHide }: Props) { + const [generateRandom, setGenerateRandom] = useState(true); + const [password, setPassword] = useState(''); + const [forceChange, setForceChange] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [results, setResults] = useState([]); + const [step, setStep] = useState<'input' | 'result'>('input'); + + const generateRandomPassword = () => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; + let result = ''; + for (let i = 0; i < 16; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }; + + const handleSubmit = async () => { + setError(null); + setLoading(true); + + try { + const entries = usernames.map(username => ({ + username, + password: generateRandom ? generateRandomPassword() : password, + })); + + const response = await api.batchSetPasswords(entries, forceChange); + + const pwResults: PasswordResult[] = entries.map(entry => { + const r = response.results.find(r => r.username === entry.username); + return { + username: entry.username, + password: entry.password, + status: r?.status === 'success' ? 'success' as const : 'failed' as const, + error: r?.error, + }; + }); + + setResults(pwResults); + setStep('result'); + + if (response.failed > 0) { + setError(`${response.failed} password(s) failed to set`); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to set passwords'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setGenerateRandom(true); + setPassword(''); + setForceChange(true); + setError(null); + setResults([]); + setStep('input'); + onHide(); + }; + + const successResults = useMemo( + () => results.filter(r => r.status === 'success'), + [results] + ); + + const copyToClipboard = () => { + const text = successResults + .map(r => `${r.username}\t${r.password}`) + .join('\n'); + navigator.clipboard.writeText(text); + }; + + const downloadCsv = () => { + const header = 'username,password,status\n'; + const rows = results + .map(r => `${r.username},${r.status === 'success' ? r.password : ''},${r.status}`) + .join('\n'); + const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `passwords-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + {step === 'input' ? `Reset Passwords (${usernames.length} users)` : 'Password Reset Results'} + + + + {step === 'input' ? ( +
+ {error && {error}} + + + This will reset passwords for {usernames.length} selected user(s). + Users will need to use the new passwords to log in. + + +
+ Users:{' '} + {usernames.slice(0, 10).map(name => ( + {name} + ))} + {usernames.length > 10 && ( + +{usernames.length - 10} more + )} +
+ + + setGenerateRandom(e.target.checked)} + /> + + + {!generateRandom && ( + + Password (same for all users) + + setPassword(e.target.value)} + placeholder="Enter password" + minLength={8} + /> + + + + Minimum 8 characters + + + )} + + + setForceChange(e.target.checked)} + /> + +
+ ) : ( +
+ {(() => { + const successCount = successResults.length; + const failedCount = results.length - successCount; + return ( + <> + {successCount > 0 && ( + + {successCount} password(s) reset successfully. + + )} + {failedCount > 0 && ( + + {failedCount} password(s) failed to set. + + )} + + ); + })()} + {error && ( + + {error} + + )} +

+ Copy the new credentials and share them with the users: +

+
+ + + + + + + + + + {results.map((r) => ( + + + + + + ))} + +
UsernamePasswordStatus
{r.username} + {r.status === 'success' ? ( + {r.password} + ) : ( + (failed) + )} + + {r.status === 'success' ? ( + OK + ) : ( + Failed + )} +
+
+ {forceChange && ( + + Users will be prompted to change their password on next login. + + )} +
+ )} +
+ + {step === 'input' ? ( + <> + + + + ) : ( + <> + + + + + )} + +
+ ); +} diff --git a/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx b/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx index d19d5e1..a1b87c9 100644 --- a/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx @@ -17,22 +17,28 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { useState } from 'react'; -import { Modal, Button, Form, Alert, Spinner, InputGroup } from 'react-bootstrap'; +import { useState, useCallback, useMemo } from 'react'; +import { Modal, Button, Form, Alert, Spinner, InputGroup, Row, Col, Badge } from 'react-bootstrap'; import * as api from '@auplc/shared'; interface Props { show: boolean; onHide: () => void; onSuccess: () => void; + quotaEnabled?: boolean; + defaultQuota?: number; } interface CreatedUser { username: string; password: string; + status: 'created' | 'existed' | 'failed'; + passwordSet: boolean; + quotaSet: boolean; + error?: string; } -export function CreateUserModal({ show, onHide, onSuccess }: Props) { +export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, defaultQuota = 0 }: Props) { const [usernames, setUsernames] = useState(''); const [password, setPassword] = useState(''); const [generateRandom, setGenerateRandom] = useState(true); @@ -42,6 +48,16 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { const [error, setError] = useState(null); const [createdUsers, setCreatedUsers] = useState([]); const [step, setStep] = useState<'input' | 'result'>('input'); + const [prefix, setPrefix] = useState(''); + const [count, setCount] = useState(10); + const [startNum, setStartNum] = useState(1); + const [quotaValue, setQuotaValue] = useState(String(defaultQuota || 0)); + + const handleGenerateNames = useCallback(() => { + if (!prefix.trim()) return; + const names = Array.from({ length: count }, (_, i) => `${prefix.trim()}${startNum + i}`); + setUsernames(names.join('\n')); + }, [prefix, count, startNum]); const generateRandomPassword = () => { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; @@ -64,27 +80,119 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { .filter(n => n.length > 0); if (names.length === 0) { - throw new Error('Please enter at least one username'); + setError('Please enter at least one username'); + setLoading(false); + return; } - const results: CreatedUser[] = []; + // Generate passwords for all users upfront + const passwordMap = new Map( + names.map(username => [ + username, + generateRandom ? generateRandomPassword() : password, + ]) + ); - for (const username of names) { - // Create user - await api.createUser(username, isAdmin); + // Initialize result tracking + const results: Map = new Map( + names.map(username => [ + username, + { username, password: passwordMap.get(username)!, status: 'created' as const, passwordSet: false, quotaSet: false }, + ]) + ); - // Set password - const pwd = generateRandom ? generateRandomPassword() : password; - await api.setPassword({ + const warnings: string[] = []; + + // Step 1: Batch create users + let createdNames: string[] = []; + try { + const created = await api.createUsers(names, isAdmin); + // API returns only newly created users; existing ones are silently skipped + createdNames = created.map(u => u.name); + const existedNames = names.filter(n => !createdNames.includes(n)); + for (const name of existedNames) { + const r = results.get(name)!; + r.status = 'existed'; + } + if (existedNames.length > 0) { + warnings.push(`${existedNames.length} user(s) already existed: ${existedNames.join(', ')}`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + // If 409 (all users exist), mark them all as existed and continue with password/quota + if (msg.includes('already exist')) { + for (const name of names) { + results.get(name)!.status = 'existed'; + } + createdNames = []; + warnings.push(`All ${names.length} user(s) already existed`); + } else { + // Fatal error - can't determine which users were created + setError(`Failed to create users: ${msg}`); + setLoading(false); + return; + } + } + + // Step 2: Set passwords (only for newly created users) + if (createdNames.length > 0) { + const passwordEntries = createdNames.map(username => ({ username, - password: pwd, - force_change: forceChange, - }); + password: passwordMap.get(username)!, + })); + + try { + const pwResult = await api.batchSetPasswords(passwordEntries, forceChange); + for (const r of pwResult.results) { + const entry = results.get(r.username); + if (entry) { + if (r.status === 'success') { + entry.passwordSet = true; + } else { + entry.error = r.error || 'Password set failed'; + } + } + } + if (pwResult.failed > 0) { + warnings.push(`${pwResult.failed} password(s) failed to set`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + warnings.push(`Password setting failed: ${msg}`); + } + } + + // Step 3: Set quota if enabled (only for newly created users) + if (quotaEnabled && createdNames.length > 0) { + const input = quotaValue.trim(); + const isUnlimited = input === '-1' || input === '∞' || input.toLowerCase() === 'unlimited'; + const amount = isUnlimited ? 0 : (parseInt(input) || 0); + if (isUnlimited || amount > 0) { + try { + await api.batchSetQuota( + createdNames.map(username => ({ + username, + amount, + ...(isUnlimited ? { unlimited: true } : {}), + })) + ); + for (const name of createdNames) { + const entry = results.get(name); + if (entry) entry.quotaSet = true; + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + warnings.push(`Quota setting failed: ${msg}`); + } + } + } - results.push({ username, password: pwd }); + // Set warnings as non-fatal error for display + if (warnings.length > 0) { + setError(warnings.join('\n')); } - setCreatedUsers(results); + setCreatedUsers(Array.from(results.values())); setStep('result'); onSuccess(); } catch (err) { @@ -103,21 +211,44 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { setError(null); setCreatedUsers([]); setStep('input'); + setPrefix(''); + setCount(10); + setStartNum(1); + setQuotaValue(String(defaultQuota || 0)); onHide(); }; + const usersWithPasswords = useMemo( + () => createdUsers.filter(u => u.passwordSet), + [createdUsers] + ); + const copyToClipboard = () => { - const text = createdUsers + const text = usersWithPasswords .map(u => `${u.username}\t${u.password}`) .join('\n'); navigator.clipboard.writeText(text); }; + const downloadCsv = () => { + const header = 'username,password,status\n'; + const rows = createdUsers + .map(u => `${u.username},${u.passwordSet ? u.password : ''},${u.status}`) + .join('\n'); + const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `users-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( - {step === 'input' ? 'Create Users' : 'Users Created'} + {step === 'input' ? 'Create Users' : 'Results'} @@ -125,6 +256,57 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) {
{error && {error}} + + Quick generate + + + setPrefix(e.target.value)} + placeholder="Prefix, e.g. student" + /> + + + + from + setStartNum(parseInt(e.target.value) || 1)} + style={{ width: 70 }} + /> + + + + + count + setCount(parseInt(e.target.value) || 1)} + style={{ width: 70 }} + /> + + + + + + + + Usernames (one per line) + {quotaEnabled && ( + + Initial Quota + setQuotaValue(e.target.value)} + placeholder="e.g. 100, or -1 for unlimited" + /> + + Leave as 0 to skip. Use -1 or "unlimited" for unlimited. + + + )} + setIsAdmin(e.target.checked)} /> +
) : (
- - Successfully created {createdUsers.length} user(s)! - + {(() => { + const newUsers = createdUsers.filter(u => u.status === 'created'); + const existedUsers = createdUsers.filter(u => u.status === 'existed'); + const failedPw = newUsers.filter(u => !u.passwordSet).length; + return ( + <> + {newUsers.length > 0 && ( + + {newUsers.length} user(s) created successfully. + + )} + {existedUsers.length > 0 && ( + + {existedUsers.length} user(s) already existed and were skipped (no changes made). + + )} + {failedPw > 0 && ( + + {failedPw} newly created user(s) failed to set password. + + )} + {newUsers.length === 0 && existedUsers.length > 0 && ( + + No new users were created. All usernames already exist in the system. + + )} + + ); + })()} + {error && ( + + {error.split('\n').map((line, i) => ( +
{line}
+ ))}
+
+ )}

Copy the credentials below and share them with the users:

@@ -203,13 +434,35 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { Username Password + Status {createdUsers.map((user) => ( {user.username} - {user.password} + + {user.passwordSet ? ( + {user.password} + ) : user.status === 'existed' ? ( + - + ) : ( + (failed) + )} + + + {user.status === 'created' ? ( + user.passwordSet ? ( + New + ) : ( + PW failed + ) + ) : user.status === 'existed' ? ( + Skipped + ) : ( + Failed + )} + ))} @@ -246,8 +499,11 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { ) : ( <> - + - {isNativeUser(user) && user.name !== 'admin' && ( + {isNativeUser(user) && ( )} - {user.name !== 'admin' && ( + {!isProtected && ( )} + +
); } diff --git a/runtime/hub/frontend/packages/shared/src/api/quota.ts b/runtime/hub/frontend/packages/shared/src/api/quota.ts index d3b885e..feaf226 100644 --- a/runtime/hub/frontend/packages/shared/src/api/quota.ts +++ b/runtime/hub/frontend/packages/shared/src/api/quota.ts @@ -44,7 +44,7 @@ export async function setUserQuota( } export async function batchSetQuota( - users: Array<{ username: string; amount: number }> + users: Array<{ username: string; amount: number; unlimited?: boolean }> ): Promise<{ success: number; failed: number }> { return adminApiRequest<{ success: number; failed: number }>("/quota/batch", { method: "POST", diff --git a/runtime/hub/frontend/packages/shared/src/api/users.ts b/runtime/hub/frontend/packages/shared/src/api/users.ts index d73b184..70c3ed5 100644 --- a/runtime/hub/frontend/packages/shared/src/api/users.ts +++ b/runtime/hub/frontend/packages/shared/src/api/users.ts @@ -151,6 +151,16 @@ export async function setPassword( }); } +export async function batchSetPasswords( + users: Array<{ username: string; password: string }>, + force_change = true +): Promise<{ success: number; failed: number; results: Array<{ username: string; status: string; error?: string }> }> { + return adminApiRequest("/batch-set-password", { + method: "POST", + body: JSON.stringify({ users, force_change }), + }); +} + export async function generatePassword(): Promise<{ password: string }> { return adminApiRequest<{ password: string }>("/generate-password", { method: "GET", diff --git a/runtime/hub/frontend/packages/shared/src/types/quota.ts b/runtime/hub/frontend/packages/shared/src/types/quota.ts index 0630425..ecbcf35 100644 --- a/runtime/hub/frontend/packages/shared/src/types/quota.ts +++ b/runtime/hub/frontend/packages/shared/src/types/quota.ts @@ -42,6 +42,7 @@ export interface QuotaRates { rates: Record; minimum_to_start: number; enabled: boolean; + default_quota?: number; } export interface UserQuotaInfo {