keepup-admin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
keepup/auth/routes.py ADDED
@@ -0,0 +1,660 @@
1
+ """Signing in, the panel session and the management of users.
2
+
3
+ The endpoints here are the framework's: they work against the ``users`` table
4
+ and the configured authentication provider, and know nothing about what the
5
+ application sells. What the application does add -- documents a user must
6
+ accept, a notice when an account is blocked -- arrives through the plugin
7
+ handlers and through the panel gate in ``keepup.auth.dependencies``.
8
+ """
9
+
10
+ import inspect
11
+ import logging
12
+ import os
13
+ import re
14
+ from datetime import datetime, timedelta
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import bcrypt
18
+ from fastapi import Depends, HTTPException, Request, Response, status
19
+ from jose import JWTError, jwt
20
+ from pydantic import BaseModel, Field, field_validator
21
+
22
+ from keepup.auth import panel_session
23
+ from keepup.auth.config import AuthProviderType, auth_config
24
+ from keepup.auth.dependencies import (
25
+ _authenticated_by_cookie,
26
+ _bearer_of,
27
+ authenticate,
28
+ create_user,
29
+ create_access_token,
30
+ get_all_users,
31
+ get_current_admin,
32
+ get_current_user,
33
+ get_user_by_id,
34
+ get_user_by_username,
35
+ get_user_by_username_async,
36
+ issue_session_token,
37
+ update_user,
38
+ verify_password,
39
+ )
40
+ from keepup.auth.dto.token import Token
41
+ from keepup.auth.factory import AuthProviderFactory
42
+ from keepup.auth.providers.base import ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM
43
+ from keepup.auth.signing_key import resolve_signing_key
44
+ from keepup.db import DatabaseManager, DatabaseManagerV2, db_config
45
+ from keepup.instance import get_instance_id
46
+ from keepup.roles import ROLE_ADMIN, ROLE_CLIENT
47
+
48
+ #: What an application may import from this module. Everything else is
49
+ #: internal and may change without notice -- see doc/keepup.md.
50
+ __all__ = [
51
+ "SELF_REGISTRATION_ENV",
52
+ "UserCreate",
53
+ "UserResponse",
54
+ "configure",
55
+ "get_user_from_database_by_username",
56
+ "refresh_access_token",
57
+ "register",
58
+ "configure_public_config",
59
+ "self_registration_enabled",
60
+ "update_user_profile",
61
+ ]
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ #: Whether anyone may create an account without an invitation.
66
+ SELF_REGISTRATION_ENV = "SELF_REGISTRATION_ENABLED"
67
+
68
+ #: Supplied by the application: how strict a password must be, and where a
69
+ #: successful sign-in is written down. The framework has no opinion on either
70
+ #: -- the password rule is shared with the application's invitation path, and
71
+ #: what belongs in an audit trail is the application's catalogue of events.
72
+ password_rule = None
73
+ record_login = None
74
+
75
+
76
+ _UNSET = object()
77
+
78
+
79
+ def _documents_pending(user: dict) -> bool:
80
+ """Whether the application says this person has documents left to accept.
81
+
82
+ Args:
83
+ user: The signed-in user.
84
+
85
+ Returns:
86
+ False when the application declared no documents at all -- which is
87
+ every application but one, and then the panel shell must not ask for
88
+ them: the route belongs to the application that has them.
89
+ """
90
+ from keepup.auth import dependencies
91
+
92
+ rule = dependencies.pending_documents
93
+ if rule is None:
94
+ return False
95
+ try:
96
+ return bool(rule(user))
97
+ except Exception as error:
98
+ # A failure here must not stand between a person and the panel: the
99
+ # gate itself refuses on its own terms, this is only the hint for the
100
+ # shell.
101
+ logger.warning(f"Could not tell whether documents are pending: {error}")
102
+ return False
103
+
104
+
105
+ def configure(password=_UNSET, login_record=_UNSET):
106
+ """Supply the application's password rule and sign-in record.
107
+
108
+ None is a value, not a silence: an application that holds passwords to no
109
+ rule of its own has to be able to say so without inheriting the previous
110
+ one's.
111
+ """
112
+ global password_rule, record_login
113
+ if password is not _UNSET:
114
+ password_rule = password
115
+ if login_record is not _UNSET:
116
+ record_login = login_record
117
+
118
+
119
+
120
+ class UserBase(BaseModel):
121
+ """The fields every shape of a user shares."""
122
+
123
+ username: str
124
+ class LoginRequest(BaseModel):
125
+ """Credentials presented at sign-in."""
126
+
127
+ username: str
128
+ password: str
129
+ class UserCreate(UserBase):
130
+ """A registration, validated before it reaches the database."""
131
+
132
+ password: str
133
+ email: Optional[str] = None
134
+ phone: Optional[str] = None
135
+ full_name: Optional[str] = None
136
+ agree_terms: bool = Field(False, description="Agreement with the terms of use")
137
+
138
+ @field_validator('email')
139
+ def validate_email(cls, v):
140
+ if v and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v):
141
+ raise ValueError('Invalid email format')
142
+ return v
143
+
144
+ @field_validator('phone')
145
+ def validate_phone(cls, v):
146
+ if v:
147
+ v = re.sub(r'\D', '', v)
148
+ if len(v) < 10:
149
+ raise ValueError('Phone number must be at least 10 digits')
150
+ return v
151
+
152
+ @field_validator('agree_terms')
153
+ def validate_agree_terms(cls, v):
154
+ if not v:
155
+ raise ValueError('You must agree to the terms of use')
156
+ return v
157
+ class UserResponse(BaseModel):
158
+ """A user as the API describes them; no password field ever leaves here."""
159
+
160
+ id: int
161
+ username: str
162
+ email: Optional[str] = None
163
+ phone: Optional[str] = None
164
+ full_name: Optional[str] = None
165
+ agree_terms: bool = False
166
+ status: str
167
+ role: str
168
+ created_at: datetime
169
+ updated_at: Optional[datetime] = None
170
+ class UserUpdateRequest(BaseModel):
171
+ """What an administrator may change about a user."""
172
+
173
+ status: Optional[str] = None
174
+ role: Optional[str] = None
175
+ email: Optional[str] = None
176
+ phone: Optional[str] = None
177
+ full_name: Optional[str] = None
178
+
179
+ @field_validator('email')
180
+ def validate_email(cls, v):
181
+ if v and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v):
182
+ raise ValueError('Invalid email format')
183
+ return v
184
+ def get_user_from_database_by_username(username: str):
185
+ """Read a user straight from the database, bypassing the auth provider."""
186
+ try:
187
+ query = '''
188
+ SELECT
189
+ id, username, password_hash, status, role,
190
+ email, phone, full_name, agree_terms,
191
+ created_at, updated_at,
192
+ external_id, auth_source, last_external_sync
193
+ FROM users
194
+ WHERE username = ?
195
+ '''
196
+ return DatabaseManager.execute_sql_one(query, (username,))
197
+ except Exception as e:
198
+ logger.error(f"Error getting user from database by username {username}: {str(e)}")
199
+ return None
200
+ def self_registration_enabled(environ=None) -> bool:
201
+ """Whether the registration form is open in this deployment."""
202
+ environ = environ if environ is not None else os.environ
203
+ return str(environ.get(SELF_REGISTRATION_ENV, "")).strip().lower() in (
204
+ "1", "true", "yes", "on")
205
+ class UserProfileUpdate(BaseModel):
206
+ """What a user may change about themselves."""
207
+
208
+ email: Optional[str] = None
209
+ phone: Optional[str] = None
210
+ full_name: Optional[str] = None
211
+
212
+ @field_validator('email')
213
+ def validate_email(cls, v):
214
+ if v and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v):
215
+ raise ValueError('Invalid email format')
216
+ return v
217
+ def update_user_profile(user_id: int, email: str = None, phone: str = None, full_name: str = None):
218
+ """Update a user's profile."""
219
+ update_fields = []
220
+ params = []
221
+
222
+ if email is not None:
223
+ update_fields.append("email = ?")
224
+ params.append(email)
225
+
226
+ if phone is not None:
227
+ update_fields.append("phone = ?")
228
+ params.append(phone)
229
+
230
+ if full_name is not None:
231
+ update_fields.append("full_name = ?")
232
+ params.append(full_name)
233
+
234
+ if not update_fields:
235
+ return False
236
+
237
+ update_fields.append("updated_at = CURRENT_TIMESTAMP")
238
+ params.append(user_id)
239
+ query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = ?"
240
+
241
+ if db_config.is_postgres():
242
+ query = query.replace('?', '%s')
243
+
244
+ try:
245
+ result = DatabaseManager.execute_commit_only(query, params)
246
+ return result > 0
247
+ except Exception as e:
248
+ logger.error(f"Error updating user profile: {str(e)}")
249
+ return False
250
+ class UserPasswordUpdate(BaseModel):
251
+ """A password change, held to the application's own rule."""
252
+
253
+ new_password: str = Field(..., min_length=6, description="New password, at least 6 characters")
254
+ async def notify_account_blocked(manager, user_id: int, admin: dict) -> None:
255
+ """Let every plugin that stops something for a blocked account do so."""
256
+ for plugin_id, plugin in manager.plugins.items():
257
+ handler = (plugin.get_handlers() or {}).get("on_account_blocked") \
258
+ if getattr(plugin, "initialized", False) else None
259
+ if not handler:
260
+ continue
261
+ try:
262
+ await handler(user_id, admin)
263
+ except Exception as e:
264
+ logger.error(f"Plugin {plugin_id} failed to stop what blocked account {user_id} runs: {e}")
265
+
266
+ async def refresh_access_token(current_user: dict, request: Request = None,
267
+ response: Response = None):
268
+ """Exchange a still-valid token for a new one with a full lifetime.
269
+
270
+ Authorised by the token being exchanged and nothing else: the holder has
271
+ already proved its right by presenting it, and the host agent keeps no
272
+ password in memory.
273
+
274
+ An expired token cannot be exchanged -- get_current_user rejects it before
275
+ this handler runs -- because renewing one would make the expiry mean
276
+ nothing: a token issued once would live forever. A blocked account is
277
+ refused for the same reason it is refused everywhere else: status is
278
+ checked on every request, not only at login.
279
+ """
280
+ from keepup.auth import session_lifetime
281
+
282
+ session_started_at = current_user.get("session_started_at")
283
+ if not session_lifetime.is_renewable(session_started_at):
284
+ raise HTTPException(
285
+ status_code=status.HTTP_401_UNAUTHORIZED,
286
+ detail="This session has run its course. Sign in again.",
287
+ )
288
+
289
+ # Carried forward, not restarted: the window counts from the login, and the
290
+ # session stays the one a logout will revoke. A token from before sessions
291
+ # were recorded gets one here.
292
+ issued = issue_session_token(current_user["id"], current_user["username"],
293
+ sid=current_user.get("session_id"),
294
+ session_started_at=session_started_at)
295
+ if request is not None and response is not None and _authenticated_by_cookie(request):
296
+ panel_session.set_cookies(response, request, issued["access_token"], issued["expires_in"])
297
+
298
+ logger.info(f"Refreshed access token for {current_user['username']}")
299
+
300
+ return {
301
+ "access_token": issued["access_token"],
302
+ "token_type": "bearer",
303
+ "expires_in": issued["expires_in"],
304
+ "success": True
305
+ }
306
+
307
+
308
+ async def register(user: UserCreate):
309
+ if not self_registration_enabled():
310
+ # Not "your account is blocked", which is what a person used to meet
311
+ # after filling this in: that answer names a state they cannot act on,
312
+ # while this one names the way in.
313
+ raise HTTPException(
314
+ status_code=status.HTTP_403_FORBIDDEN,
315
+ detail="Registration is by invitation. Ask our Telegram bot for an "
316
+ "invitation link, and it will let you set your own password.",
317
+ )
318
+
319
+ # The same password rule as the invitation path. Two ways into one system
320
+ # cannot hold a password to two different standards -- and this way held it
321
+ # to none at all, while an invitation asked for ten characters.
322
+ problem = password_rule(user.password, user.username) if password_rule else None
323
+ if problem:
324
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=problem)
325
+
326
+ try:
327
+ user_id = await create_user(
328
+ username=user.username,
329
+ password=user.password,
330
+ email=user.email,
331
+ phone=user.phone,
332
+ full_name=user.full_name,
333
+ agree_terms=user.agree_terms
334
+ )
335
+
336
+ return {
337
+ "success": True,
338
+ "message": "User registered successfully. Please wait for administrator activation.",
339
+ "user_id": user_id
340
+ }
341
+ except HTTPException as e:
342
+ raise e
343
+ except Exception as e:
344
+ logger.error(f"Error in user registration: {str(e)}")
345
+ raise HTTPException(
346
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
347
+ detail="Error registering user"
348
+ )
349
+
350
+
351
+ #: Set by the application through configure(): what else the sign-in screen
352
+ #: needs. The framework has no terms and no password-reset address of its own.
353
+ application_public_config = None
354
+
355
+
356
+ def configure_public_config(supplier):
357
+ global application_public_config
358
+ application_public_config = supplier
359
+
360
+
361
+ def register_auth_routes(app, manager):
362
+ """Register sign-in, session and user management on the application.
363
+
364
+ ``manager`` is the plugin manager: blocking an account has to reach the
365
+ plugins that run things for that account.
366
+ """
367
+
368
+ @app.get("/api/public/config")
369
+ async def public_config_endpoint():
370
+ """What the sign-in screen needs before anyone has signed in.
371
+
372
+ The framework answers what it knows itself and lets the application
373
+ fill in the rest. Before task keepup-27 the whole endpoint belonged to
374
+ one application, and the panel shell -- which the framework ships --
375
+ called it in every application, including the two that never had it.
376
+ """
377
+ config = {"self_registration": self_registration_enabled()}
378
+ if application_public_config is None:
379
+ return config
380
+ try:
381
+ extra = application_public_config()
382
+ if inspect.isawaitable(extra):
383
+ extra = await extra
384
+ config.update(extra or {})
385
+ except Exception as error:
386
+ # The sign-in screen must come up: without the terms the register
387
+ # form shows none, which is better than no screen at all.
388
+ logger.warning(f"Could not read the application's public config: {error}")
389
+ return config
390
+
391
+ @app.post("/api/auth/register", response_model=dict)
392
+ async def register_endpoint(user: UserCreate):
393
+ return await register(user)
394
+
395
+ @app.post("/api/auth/login", response_model=Token)
396
+ async def login(login_data: LoginRequest, request: Request, response: Response):
397
+ from keepup.auth import login_throttle
398
+
399
+ # Guessing a password had no cost: every attempt was answered as fast as the
400
+ # first. The count is per name and lives in the database, because the next
401
+ # attempt may well be served by another replica.
402
+ locked_until = login_throttle.locked_until(login_data.username)
403
+ if locked_until:
404
+ # Deliberately says nothing about whether the name exists or the password
405
+ # was right -- answering that here would turn the lockout into an oracle.
406
+ raise HTTPException(
407
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
408
+ detail="Too many sign-in attempts. Try again later.",
409
+ )
410
+
411
+ user = await get_user_by_username_async(login_data.username)
412
+ if not user or not await authenticate(login_data.username, login_data.password):
413
+ login_throttle.record_failure(login_data.username)
414
+ raise HTTPException(
415
+ status_code=status.HTTP_401_UNAUTHORIZED,
416
+ detail="Incorrect username or password",
417
+ headers={"WWW-Authenticate": "Bearer"},
418
+ )
419
+
420
+ login_throttle.record_success(login_data.username)
421
+
422
+ if user["status"] != "active":
423
+ raise HTTPException(
424
+ status_code=status.HTTP_403_FORBIDDEN,
425
+ detail="User account is blocked. Please contact administrator.",
426
+ )
427
+
428
+ issued = issue_session_token(user["id"], user["username"])
429
+ access_token = issued["access_token"]
430
+ # The panel keeps the session in a cookie its scripts cannot read; the token
431
+ # in the body stays for the host agent and scripts, which send it as Bearer.
432
+ panel_session.set_cookies(response, request, access_token, issued["expires_in"])
433
+
434
+ if record_login is not None:
435
+ await record_login(user)
436
+
437
+ return {
438
+ "access_token": access_token,
439
+ "token_type": "bearer",
440
+ # How long the token has, so the holder need not decode the JWT to
441
+ # learn what the server already knows. The agent schedules its renewal
442
+ # off this: an expired token cannot buy a websocket key, and the host
443
+ # then drops out of the system with the machine still running.
444
+ "expires_in": issued["expires_in"],
445
+ "success": True
446
+ }
447
+ @app.post("/api/auth/refresh")
448
+ async def refresh_access_token_endpoint(current_user: dict = Depends(get_current_user),
449
+ request: Request = None, response: Response = None):
450
+ """Exchange a still-valid token for a new one with a full lifetime."""
451
+ return await refresh_access_token(current_user, request, response)
452
+
453
+ @app.post("/api/auth/logout")
454
+ async def logout(request: Request, response: Response):
455
+ """End this session on the server and forget the cookies.
456
+
457
+ Answers 200 even without a live session: the browser has to be able to clear
458
+ a cookie whose session is already gone. A POST, with the CSRF check of any
459
+ cookie request -- a logout by GET could be triggered by an image elsewhere.
460
+ """
461
+ token = panel_session.token_from_request(request, _bearer_of(request))
462
+ revoked = False
463
+ if token:
464
+ try:
465
+ payload = jwt.decode(token, resolve_signing_key(), algorithms=[ALGORITHM])
466
+ sid = payload.get(panel_session.SESSION_CLAIM)
467
+ if sid:
468
+ revoked = panel_session.revoke(sid, panel_session.REASON_LOGOUT) > 0
469
+ except JWTError:
470
+ pass
471
+ panel_session.clear_cookies(response)
472
+ return {"success": True, "revoked": revoked}
473
+ @app.post("/api/auth/session")
474
+ async def exchange_token_for_session(request: Request, response: Response,
475
+ current_user: dict = Depends(get_current_user)):
476
+ """Move a session the page holds as a token into the cookie.
477
+
478
+ For the invitation page, whose session is issued by a plugin that cannot set
479
+ a cookie, and for a panel tab still holding a token from before the cookie.
480
+ The token is not returned: the point is that the page stops holding one.
481
+ """
482
+ issued = issue_session_token(current_user["id"], current_user["username"],
483
+ sid=current_user.get("session_id"),
484
+ session_started_at=current_user.get("session_started_at"))
485
+ panel_session.set_cookies(response, request, issued["access_token"], issued["expires_in"])
486
+ return {"success": True, "expires_in": issued["expires_in"]}
487
+ @app.get("/api/auth/verify")
488
+ async def verify_token(current_user: dict = Depends(get_current_user)):
489
+ """Validate the caller's token."""
490
+ return {
491
+ "success": True,
492
+ "user": {
493
+ "id": current_user["id"],
494
+ "username": current_user["username"],
495
+ "status": current_user["status"],
496
+ "role": current_user["role"],
497
+ "created_at": current_user["created_at"]
498
+ }
499
+ }
500
+ @app.put("/api/auth/profile", response_model=dict)
501
+ async def update_profile(
502
+ profile_data: UserProfileUpdate,
503
+ current_user: dict = Depends(get_current_user)
504
+ ):
505
+ """Update the current user's profile."""
506
+ success = update_user_profile(
507
+ user_id=current_user["id"],
508
+ email=profile_data.email,
509
+ phone=profile_data.phone,
510
+ full_name=profile_data.full_name
511
+ )
512
+
513
+ if success:
514
+ return {"success": True, "message": "Profile updated successfully"}
515
+ else:
516
+ raise HTTPException(status_code=500, detail="Error updating profile")
517
+ @app.get("/api/auth/me")
518
+ async def get_me(current_user: dict = Depends(get_current_user)):
519
+ """Return the current user."""
520
+ return {
521
+ "id": current_user["id"],
522
+ "username": current_user["username"],
523
+ "email": current_user.get("email"),
524
+ "phone": current_user.get("phone"),
525
+ "full_name": current_user.get("full_name"),
526
+ "status": current_user["status"],
527
+ "role": current_user["role"],
528
+ "created_at": current_user["created_at"],
529
+ "updated_at": current_user.get("updated_at"),
530
+ # Whether this person has documents left to accept. The framework
531
+ # has no documents of its own and does not serve them -- an
532
+ # application that has them registers its own routes. What the
533
+ # framework can say is whether there is anything to ask for, and
534
+ # that is what keeps the panel shell from calling a route that
535
+ # exists in one application out of three.
536
+ "documents_pending": _documents_pending(current_user),
537
+ }
538
+ @app.put("/api/admin/users/{user_id}/password", response_model=dict)
539
+ async def admin_change_user_password(
540
+ user_id: int,
541
+ password_data: UserPasswordUpdate,
542
+ admin: dict = Depends(get_current_admin)
543
+ ):
544
+ """Change a user's password as an administrator.
545
+
546
+ Requires:
547
+ - administrator rights
548
+ - a new password of at least 6 characters.
549
+ """
550
+ try:
551
+ user = get_user_by_id(user_id)
552
+ if not user:
553
+ raise HTTPException(status_code=404, detail="User not found")
554
+
555
+ # The application's rule, the same one registration is held to. It
556
+ # used to apply on one of the two paths that set a password: a
557
+ # deployment asking for twelve characters got them at registration
558
+ # and lost them here, without a word (task keepup-14).
559
+ problem = (password_rule(password_data.new_password, user['username'])
560
+ if password_rule else None)
561
+ if problem:
562
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=problem)
563
+
564
+ new_password_hash = bcrypt.hashpw(
565
+ password_data.new_password.encode('utf-8'),
566
+ bcrypt.gensalt()
567
+ ).decode('utf-8')
568
+
569
+ query = '''
570
+ UPDATE users
571
+ SET password_hash = ?
572
+ WHERE id = ?
573
+ '''
574
+
575
+ if db_config.is_postgres():
576
+ query = query.replace('?', '%s')
577
+
578
+ conn = DatabaseManager.get_connection()
579
+ cursor = conn.cursor()
580
+
581
+ try:
582
+ cursor.execute(query, (new_password_hash, user_id))
583
+ conn.commit()
584
+ # A new password that leaves the old sessions working changes nothing
585
+ # for whoever took the old one.
586
+ sessions_revoked = panel_session.revoke_all(user_id, panel_session.REASON_PASSWORD_CHANGED)
587
+ logger.info(
588
+ f"Administrator {admin['username']} changed the password of "
589
+ f"{user['username']} (ID: {user_id})"
590
+ )
591
+
592
+ DatabaseManager.execute_commit_only('''
593
+ INSERT INTO system_metrics (metric_name, metric_value, app_instance, tags)
594
+ VALUES (?, ?, ?, ?)
595
+ ''', (
596
+ "admin_password_change",
597
+ 1,
598
+ get_instance_id(),
599
+ f"admin:{admin['username']},target_user:{user['username']},user_id:{user_id}"
600
+ ))
601
+
602
+ return {
603
+ "success": True,
604
+ "message": f"Password for user {user['username']} changed successfully",
605
+ "sessions_revoked": sessions_revoked,
606
+ }
607
+
608
+ finally:
609
+ cursor.close()
610
+ conn.close()
611
+
612
+ except HTTPException:
613
+ raise
614
+ except Exception as e:
615
+ logger.error(f"Could not change the password of user {user_id}: {str(e)}")
616
+ raise HTTPException(
617
+ status_code=500,
618
+ detail="Error changing password"
619
+ )
620
+ @app.get("/api/admin/users", response_model=List[UserResponse])
621
+ async def get_all_users_endpoint(admin: dict = Depends(get_current_admin)):
622
+ users = get_all_users()
623
+ return users
624
+ @app.get("/api/admin/users/{user_id}", response_model=UserResponse)
625
+ async def get_user_endpoint(user_id: int, admin: dict = Depends(get_current_admin)):
626
+ user = get_user_by_id(user_id)
627
+ if not user:
628
+ raise HTTPException(status_code=404, detail="User not found")
629
+ return dict(user)
630
+ @app.patch("/api/admin/users/{user_id}", response_model=dict)
631
+ async def update_user_endpoint(
632
+ user_id: int,
633
+ user_data: UserUpdateRequest,
634
+ admin: dict = Depends(get_current_admin)
635
+ ):
636
+ user = get_user_by_id(user_id)
637
+ if not user:
638
+ raise HTTPException(status_code=404, detail="User not found")
639
+ if user_data.status and user_data.status != "active" and int(admin["id"]) == int(user_id):
640
+ raise HTTPException(status_code=400, detail="An administrator cannot block their own account")
641
+
642
+ update_user(
643
+ user_id,
644
+ user_data.status,
645
+ user_data.role,
646
+ user_data.email,
647
+ user_data.phone,
648
+ user_data.full_name
649
+ )
650
+
651
+ # Leaving "active" here is a block too, and must not skip what a block stops.
652
+ if user.get("status") == "active" and user_data.status and user_data.status != "active":
653
+ # The sessions go first. An HTTP request is refused anyway -- the
654
+ # status is read on every one -- but a WebSocket authenticates once
655
+ # at the handshake and then runs, so a blocked account kept whatever
656
+ # socket it already had open (task keepup-14).
657
+ panel_session.revoke_all(int(user_id), panel_session.REASON_ACCOUNT_BLOCKED)
658
+ await notify_account_blocked(manager, int(user_id), admin)
659
+
660
+ return {"success": True, "message": "User updated successfully"}