diff --git a/src/agentkit/server/app.py b/src/agentkit/server/app.py index b42c68a..955f23b 100644 --- a/src/agentkit/server/app.py +++ b/src/agentkit/server/app.py @@ -926,6 +926,7 @@ def create_app( app.include_router(terminal_whitelist.router, prefix="/api/v1") app.include_router(experts.router, prefix="/api/v1") app.include_router(auth_routes.router, prefix="/api/v1") + app.include_router(auth_routes.admin_router, prefix="/api/v1") # Serve GUI when in GUI mode gui_mode = os.environ.get("AGENTKIT_GUI_MODE") diff --git a/src/agentkit/server/auth/dependencies.py b/src/agentkit/server/auth/dependencies.py index e461a40..13ca67a 100644 --- a/src/agentkit/server/auth/dependencies.py +++ b/src/agentkit/server/auth/dependencies.py @@ -31,6 +31,14 @@ async def get_current_user(request: Request) -> dict[str, Any] | None: The payload is set by :class:`AuthMiddleware` and contains ``user_id``, ``username``, and ``role``. When no auth middleware is active (e.g. dev mode with no keys configured), returns ``None``. + + U10 back-compat: the payload may or may not carry a ``sid`` field. + Legacy V1 tokens (issued before the auth_sessions table existed) + do not have ``sid``; the caller must check ``"sid" in payload`` and + fall back to the V1 ``user_sessions`` table when the claim is + missing. This helper does NOT itself consult the session table — + that responsibility belongs to the routes / dependencies that + need a real session (e.g. :func:`get_current_session` in U4). """ return getattr(request.state, "current_user", None) @@ -47,6 +55,15 @@ async def require_authenticated(request: Request) -> dict[str, Any]: status_code=401, detail="Authentication required", ) + # U10 debug signal: log once per request when a legacy (no-sid) + # token is in use. This makes the migration window observable in + # the server log; when the count drops to zero, we can remove the + # legacy code path. + if "sid" not in user: + logger.debug( + "Legacy JWT (no sid) in use for user_id=%s — accepted via U10 back-compat path", + user.get("user_id"), + ) return user diff --git a/src/agentkit/server/auth/jwt_utils.py b/src/agentkit/server/auth/jwt_utils.py index b4865d8..35d16e9 100644 --- a/src/agentkit/server/auth/jwt_utils.py +++ b/src/agentkit/server/auth/jwt_utils.py @@ -117,6 +117,7 @@ def create_token_pair( session_id: str | None = None, remember_me: bool = False, now: datetime | None = None, + legacy_mode: bool = False, ) -> TokenPair: """Create a signed access + refresh JWT pair. @@ -134,6 +135,12 @@ def create_token_pair( remember_me: When ``True``, the refresh token is valid for 30 days instead of the default 7. now: Override the issued-at time (for testing). Defaults to UTC now. + legacy_mode: When ``True``, the resulting tokens are + intentionally issued without a ``sid`` claim, regardless of + whether ``session_id`` was passed. This is the U10 + back-compat flag used by the login route for clients with + ``X-Client-Version`` below the rollout cutoff. Default + ``False`` — production tokens always carry ``sid``. Returns: A :class:`TokenPair` with both signed tokens and their expiry times. @@ -141,6 +148,12 @@ def create_token_pair( if not secret: raise ValueError("JWT secret must not be empty") + # U10: legacy_mode forces the no-sid path even if the caller has + # already created a session row. This avoids handing a fresh sid + # to a client that doesn't know how to use it (the + # ``/auth/whoami`` route will fall back to its legacy branch). + effective_session_id: str | None = None if legacy_mode else session_id + issued_at = now or datetime.now(timezone.utc) refresh_ttl = _refresh_ttl_for(remember_me) access_exp = issued_at + ACCESS_TOKEN_TTL @@ -150,7 +163,7 @@ def create_token_pair( # sha256 of its plaintext value as the rotation handle in the # denylist; giving it a jti too would be redundant and would bloat # the JWT. - jti = str(uuid.uuid4()) if session_id else None + jti = str(uuid.uuid4()) if effective_session_id else None access_payload: dict[str, Any] = { "sub": user_id, @@ -168,10 +181,10 @@ def create_token_pair( "iat": int(issued_at.timestamp()), "exp": int(refresh_exp.timestamp()), } - if session_id: - access_payload["sid"] = session_id + if effective_session_id: + access_payload["sid"] = effective_session_id access_payload["jti"] = jti - refresh_payload["sid"] = session_id + refresh_payload["sid"] = effective_session_id access_token = jwt.encode(access_payload, secret, algorithm=JWT_ALGORITHM) refresh_token = jwt.encode(refresh_payload, secret, algorithm=JWT_ALGORITHM) diff --git a/src/agentkit/server/auth/middleware.py b/src/agentkit/server/auth/middleware.py index e7930f5..0fd2e88 100644 --- a/src/agentkit/server/auth/middleware.py +++ b/src/agentkit/server/auth/middleware.py @@ -78,7 +78,16 @@ class AuthMiddleware(BaseHTTPMiddleware): return not self._jwt_secret and self._api_key is None and not self._client_keys def _verify_jwt(self, token: str) -> dict[str, Any] | None: - """Verify a JWT bearer token. Returns payload or None.""" + """Verify a JWT bearer token. Returns payload or None. + + V2 tokens carry a ``sid`` claim. The middleware does NOT + consult the session cache here — it only checks the JWT + signature + ``type`` claim. The sid-aware validation runs + lazily in the :func:`require_authenticated` dependency when + the route opts in (see :mod:`.dependencies`). This keeps the + middleware hot path cheap and lets the per-route decision + about cache TTL be made by the route author. + """ if not self._jwt_secret: return None try: @@ -140,6 +149,7 @@ class AuthMiddleware(BaseHTTPMiddleware): "user_id": payload.get("sub"), "username": payload.get("username"), "role": payload.get("role"), + "sid": payload.get("sid"), } return await call_next(request) # Fall through to API key check, then 401 diff --git a/src/agentkit/server/auth/session_cache.py b/src/agentkit/server/auth/session_cache.py index a69bfe7..129968d 100644 --- a/src/agentkit/server/auth/session_cache.py +++ b/src/agentkit/server/auth/session_cache.py @@ -21,7 +21,6 @@ from __future__ import annotations import time from collections import OrderedDict -from typing import Any from .session_service import SessionService @@ -104,9 +103,7 @@ def init_validation_cache( a reference for tests. """ global _cache - _cache = SessionValidationCache( - service, ttl_seconds=ttl_seconds, max_entries=max_entries - ) + _cache = SessionValidationCache(service, ttl_seconds=ttl_seconds, max_entries=max_entries) return _cache diff --git a/src/agentkit/server/auth/session_service.py b/src/agentkit/server/auth/session_service.py index c76ad3e..37450af 100644 --- a/src/agentkit/server/auth/session_service.py +++ b/src/agentkit/server/auth/session_service.py @@ -228,12 +228,8 @@ class SessionService: """ session_id = str(uuid.uuid4()) now = _now_iso() - expires = ( - datetime.now(timezone.utc).timestamp() + payload.ttl_seconds - ) - expires_iso = ( - datetime.fromtimestamp(expires, tz=timezone.utc).isoformat() - ) + expires = datetime.now(timezone.utc).timestamp() + payload.ttl_seconds + expires_iso = datetime.fromtimestamp(expires, tz=timezone.utc).isoformat() refresh_hash = hash_token(payload.refresh_token) await self._enforce_session_cap(payload.user_id, keep_id=session_id) @@ -302,9 +298,7 @@ class SessionService: # Concurrent retry — revoke everything for this user. user_id = self._recent_users.pop(old_hash, None) if user_id is not None: - await self.revoke_all_for_user( - user_id, reason=REVOKE_REASON_REUSE_DETECTED - ) + await self.revoke_all_for_user(user_id, reason=REVOKE_REASON_REUSE_DETECTED) raise SessionReuseDetected("refresh token reuse detected") info = await self.find_by_refresh_token(old_refresh_token) @@ -315,12 +309,10 @@ class SessionService: new_hash = hash_token(new_refresh_token) now = _now_iso() - new_expires_iso = ( - datetime.fromtimestamp( - datetime.now(timezone.utc).timestamp() + new_ttl_seconds, - tz=timezone.utc, - ).isoformat() - ) + new_expires_iso = datetime.fromtimestamp( + datetime.now(timezone.utc).timestamp() + new_ttl_seconds, + tz=timezone.utc, + ).isoformat() async with aiosqlite.connect(str(self._db_path)) as db: await db.execute( @@ -357,9 +349,7 @@ class SessionService: # Revoke # ------------------------------------------------------------------ - async def revoke( - self, session_id: str, *, reason: str = REVOKE_REASON_USER_TERMINATED - ) -> bool: + async def revoke(self, session_id: str, *, reason: str = REVOKE_REASON_USER_TERMINATED) -> bool: """Revoke a single session. Returns ``True`` if a row was updated, ``False`` if the session diff --git a/src/agentkit/server/auth/terminal_security.py b/src/agentkit/server/auth/terminal_security.py index 7cdc8e1..69f039d 100644 --- a/src/agentkit/server/auth/terminal_security.py +++ b/src/agentkit/server/auth/terminal_security.py @@ -59,11 +59,11 @@ def _has_shell_operators(command: str) -> bool: # ── Decision constants ──────────────────────────────────────────────── -DECISION_EXECUTED = "executed" # Ran without confirmation (whitelisted) -DECISION_CONFIRMED = "confirmed" # Ran after user confirmation -DECISION_REJECTED = "rejected" # User rejected confirmation prompt -DECISION_BLOCKED = "blocked" # Blocked by blocklist -DECISION_DENIED = "denied" # Blocked by safety check (non-whitelist) +DECISION_EXECUTED = "executed" # Ran without confirmation (whitelisted) +DECISION_CONFIRMED = "confirmed" # Ran after user confirmation +DECISION_REJECTED = "rejected" # User rejected confirmation prompt +DECISION_BLOCKED = "blocked" # Blocked by blocklist +DECISION_DENIED = "denied" # Blocked by safety check (non-whitelist) # ── Pattern matching ───────────────────────────────────────────────── @@ -183,9 +183,7 @@ async def _fetch_blocklist(db_path: str | Path) -> list[tuple[str, str | None]]: patterns: list[tuple[str, str | None]] = [] try: async with aiosqlite.connect(str(db_path)) as db: - cursor = await db.execute( - "SELECT command_pattern, reason FROM terminal_blocklist" - ) + cursor = await db.execute("SELECT command_pattern, reason FROM terminal_blocklist") rows = await cursor.fetchall() patterns = [(row[0], row[1]) for row in rows] except Exception as e: diff --git a/src/agentkit/server/frontend/src/App.vue b/src/agentkit/server/frontend/src/App.vue index d4f57ff..54b3f29 100644 --- a/src/agentkit/server/frontend/src/App.vue +++ b/src/agentkit/server/frontend/src/App.vue @@ -23,6 +23,7 @@ import { setTokenProvider, setRefreshProvider, setUnauthorizedHandler, + setPreEmptiveRefreshProvider, } from './api/base' import SplashScreen from './components/layout/SplashScreen.vue' @@ -38,12 +39,22 @@ onMounted(async () => { // Wire the auth store into the API client so every request gets a JWT // attached automatically, and 401s trigger a token refresh. setTokenProvider(() => authStore.accessToken) - setRefreshProvider(() => authStore.refreshIfPossible()) + setRefreshProvider(async () => { + const pair = await authStore.silentRefresh() + return pair?.access_token ?? null + }) + setPreEmptiveRefreshProvider(() => authStore.shouldRefresh) setUnauthorizedHandler(() => { - authStore.logoutLocal() + void authStore.logoutLocal() router.replace({ name: 'login' }) }) + // Cold-start auth: rehydrate from the persisted refresh token. + // This populates user + access token WITHOUT forcing a login. + // The router guard then decides whether to keep the user where + // they are or redirect to /login. + await authStore.startupCheck() + if (isTauri()) { try { await bootstrapBackend() diff --git a/src/agentkit/server/frontend/src/api/admin.ts b/src/agentkit/server/frontend/src/api/admin.ts new file mode 100644 index 0000000..98448c8 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/admin.ts @@ -0,0 +1,74 @@ +/** + * Admin API client (U9 — Admin UI: user sessions management). + * + * Talks to the server-side ``/api/v1/admin/...`` endpoints implemented + * in ``src/agentkit/server/routes/auth.py``. The endpoints require + * ``USER_MANAGE`` permission (admin role); the API client does not + * itself enforce that — the server's 403 response is surfaced to the + * caller as an :class:`IApiError` with ``status: 403``. + * + * Surface: + * - ``listAllSessions`` — every recent session across the system + * (admin "active sessions" overview). + * - ``listUserSessions(userId)`` — every session for a specific user, + * including revoked ones when ``includeRevoked`` is true. + * - ``revokeUserSession(userId, sid)`` — force-revoke a single session + * of a single user. + */ + +import { BaseApiClient } from './base' +import type { ISessionInfo } from './auth' + +class AdminApiClient extends BaseApiClient { + constructor(baseUrl: string = '/api/v1') { + super(baseUrl) + } + + /** + * List recent sessions across all users (admin only). + * + * @param limit Maximum number of sessions to return (default 200). + */ + async listAllSessions(limit: number = 200): Promise { + return this.request( + `/admin/sessions?limit=${encodeURIComponent(String(limit))}`, + ) + } + + /** + * List every session for a specific user. + * + * @param userId The user whose sessions to list. + * @param includeRevoked When true, revoked sessions are also returned + * with their ``revoked`` and ``revoked_reason`` fields populated. + */ + async listUserSessions( + userId: string, + includeRevoked: boolean = false, + ): Promise { + const qs = includeRevoked ? '?include_revoked=true' : '' + return this.request( + `/admin/users/${encodeURIComponent(userId)}/sessions${qs}`, + ) + } + + /** + * Force-revoke a session of a specific user. + * + * The endpoint returns ``{ revoked: boolean }`` — ``false`` means the + * session was already revoked (no-op). The server returns 404 when + * the session does not exist or does not belong to ``userId``. + */ + async revokeUserSession( + userId: string, + sid: string, + ): Promise<{ revoked: boolean }> { + return this.request<{ revoked: boolean }>( + `/admin/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sid)}`, + { method: 'DELETE' }, + ) + } +} + +export const adminApi = new AdminApiClient() +export { AdminApiClient } diff --git a/src/agentkit/server/frontend/src/api/auth.ts b/src/agentkit/server/frontend/src/api/auth.ts index 8295881..e82d235 100644 --- a/src/agentkit/server/frontend/src/api/auth.ts +++ b/src/agentkit/server/frontend/src/api/auth.ts @@ -1,8 +1,17 @@ /** - * Auth API client — login / refresh / logout / me + * Auth API client — login / refresh / logout / whoami / sessions / me * * Talks to the server-side ``/api/v1/auth/*`` endpoints implemented in - * ``src/agentkit/server/routes/auth.py``. + * ``src/agentkit/server/routes/auth.py`` (U4 — Centralized Auth). + * + * U7 refactor: + * - ``whoami`` accepts an optional ``refreshToken`` for the cold-start + * path (when the access token is gone but a refresh token survives + * in the OS Keychain / localStorage fallback). + * - ``login`` accepts ``rememberMe`` to ask the server for a 30-day + * refresh token TTL. + * - New ``listSessions`` / ``revokeSession`` / ``changePassword`` + * endpoints back the Settings / Security UI (U8). */ import { BaseApiClient } from './base' @@ -25,6 +34,35 @@ export interface ITokenPair { token_type: 'bearer' expires_in: number user: IAuthUser + /** V2 sessions carry an id; legacy tokens may omit it. */ + session_id?: string +} + +/** Public projection of an ``auth_sessions`` row. */ +export interface ISessionInfo { + id: string + device_fingerprint: string + device_label: string + ip: string + user_agent: string + auth_provider: string + created_at: string + last_active_at: string + expires_at: string + is_current: boolean + /** User id — populated by admin endpoints; user-endpoint omits this. */ + user_id?: string + /** Revocation flag — admin endpoints surface this for the audit view. */ + revoked?: boolean + /** Machine-readable revoke reason. ``null`` for active sessions. */ + revoked_reason?: string | null +} + +/** Response of /auth/whoami (used for cold-start). */ +export interface IWhoamiResponse { + user: IAuthUser + access_token: string + session: ISessionInfo | null } class AuthApiClient extends BaseApiClient { @@ -33,10 +71,18 @@ class AuthApiClient extends BaseApiClient { } /** Authenticate with username + password. */ - async login(username: string, password: string): Promise { + async login( + username: string, + password: string, + rememberMe: boolean = false, + ): Promise { return this.request('/auth/login', { method: 'POST', - body: JSON.stringify({ username, password }), + body: JSON.stringify({ + username, + password, + remember_me: rememberMe, + }), }) } @@ -48,6 +94,26 @@ class AuthApiClient extends BaseApiClient { }) } + /** + * Cold-start probe. Returns the current user + a fresh access token + * + the session metadata. Used on app boot when only the refresh + * token is available. + * + * The server route accepts both access and refresh tokens (the + * cold-start case sends a refresh token via a custom header — see + * ``requestWithToken``). If you already have an access token, just + * call ``me()`` instead. + */ + async whoami(refreshToken?: string): Promise { + if (refreshToken) { + return this.requestWithToken<{ Authorization: string }, IWhoamiResponse>( + '/auth/whoami', + { Authorization: `Bearer ${refreshToken}` }, + ) + } + return this.request('/auth/whoami') + } + /** Revoke a refresh token (idempotent). */ async logout(refreshToken: string): Promise<{ revoked: boolean }> { return this.request<{ revoked: boolean }>('/auth/logout', { @@ -60,6 +126,34 @@ class AuthApiClient extends BaseApiClient { async me(): Promise { return this.request('/auth/me') } + + // --- Session management (U4 / U8) --- + + /** List the current user's active sessions. */ + async listSessions(): Promise { + return this.request('/auth/sessions') + } + + /** Revoke a single session (own only). */ + async revokeSession(sid: string): Promise<{ revoked: boolean }> { + return this.request<{ revoked: boolean }>(`/auth/sessions/${encodeURIComponent(sid)}`, { + method: 'DELETE', + }) + } + + /** Change the current user's password. Revokes all other sessions. */ + async changePassword(oldPassword: string, newPassword: string): Promise<{ changed: boolean; revoked_other_sessions: number }> { + return this.request<{ changed: boolean; revoked_other_sessions: number }>( + '/auth/change-password', + { + method: 'POST', + body: JSON.stringify({ + old_password: oldPassword, + new_password: newPassword, + }), + }, + ) + } } export const authApi = new AuthApiClient() diff --git a/src/agentkit/server/frontend/src/api/base.ts b/src/agentkit/server/frontend/src/api/base.ts index ac9c907..2132499 100644 --- a/src/agentkit/server/frontend/src/api/base.ts +++ b/src/agentkit/server/frontend/src/api/base.ts @@ -8,6 +8,16 @@ export interface IApiError { detail?: string } +/** + * Client version advertised in the ``X-Client-Version`` request header. + * + * The server-side rollout policy uses this to decide whether to issue + * legacy (no-sid) JWTs to old clients during the U10 migration window. + * Bump this when shipping breaking auth changes; the rollout doc + * explains the policy and the cutoff version. + */ +export const CLIENT_VERSION = '0.5.0' + let _dynamicBaseURL = '' /** Initialize the dynamic base URL for Tauri (sidecar backend). */ @@ -33,10 +43,12 @@ export function getDynamicBaseURL(): string { type TokenProvider = () => string | null type RefreshProvider = () => Promise type UnauthorizedHandler = () => void +type PreEmptiveRefreshProvider = () => boolean let _tokenProvider: TokenProvider | null = null let _refreshProvider: RefreshProvider | null = null let _unauthorizedHandler: UnauthorizedHandler | null = null +let _preEmptiveRefreshProvider: PreEmptiveRefreshProvider | null = null /** Register the access-token provider (called by the auth store on init). */ export function setTokenProvider(provider: TokenProvider): void { @@ -53,6 +65,16 @@ export function setUnauthorizedHandler(handler: UnauthorizedHandler): void { _unauthorizedHandler = handler } +/** + * Register a hook that returns true when the auth store considers the + * access token too close to expiry. When true, the next outgoing + * request will be preceded by a silent refresh — the user never sees + * a 401 caused by their own (legitimate) token expiring. + */ +export function setPreEmptiveRefreshProvider(provider: PreEmptiveRefreshProvider): void { + _preEmptiveRefreshProvider = provider +} + export class BaseApiClient { protected baseUrl: string @@ -88,16 +110,67 @@ export class BaseApiClient { if (token) { headers['Authorization'] = `Bearer ${token}` } + // U10 backwards-compat: tell the server what client version we are. + // The server uses this to decide whether to issue legacy (no-sid) + // tokens for old clients during the migration window. Once the + // cutoff version is removed server-side, this header becomes a + // no-op. See docs/migrations/2026-06-20-client-version-rollout.md + headers['X-Client-Version'] = CLIENT_VERSION return headers } + /** + * Send a request with a custom ``Authorization`` header that + * overrides the in-memory access token. Used by the cold-start + * ``/auth/whoami`` call where only the refresh token is available + * and the auth store's access token is empty. + */ + protected async requestWithToken, T>( + path: string, + headers: H, + options: RequestInit = {}, + ): Promise { + const response = await this._sendWithHeaders(path, options, headers) + if (!response.ok) { + throw await this._buildError(response) + } + const text = await response.text() + if (!text) { + return null as unknown as T + } + try { + return JSON.parse(text) as T + } catch { + return text as unknown as T + } + } + /** * Send a request, retrying once with a refreshed token on 401. * - * If the refresh fails, the unauthorized handler is invoked (typically - * redirecting to /login) and the original 401 error is re-thrown. + * Before each request we also call ``silentRefresh()`` when + * ``auth.shouldRefresh`` is true (pre-emptive refresh) so that a + * soon-to-expire access token never causes a 401 in the first place. + * + * If the refresh fails, the unauthorized handler is invoked + * (typically redirecting to /login) and the original 401 error is + * re-thrown. */ protected async request(path: string, options: RequestInit = {}): Promise { + // Pre-emptive refresh: if the access token is about to expire + // (within the headroom window), refresh BEFORE sending the request. + // This is the only place that triggers the pre-emptive refresh + // path; the 401-retry path below handles the case where the token + // expires between calls. + if (this._shouldPreemptivelyRefresh()) { + try { + await this._silentRefreshForPreEmption() + } catch { + /* silent refresh failed — fall through; the 401 retry path + below will produce a 401 and trigger the unauthorized handler */ + } + } + const response = await this._send(path, options) if (response.status === 401 && _refreshProvider) { @@ -108,7 +181,15 @@ export class BaseApiClient { if (!retried.ok) { throw await this._buildError(retried) } - return (retried.json() as Promise) ?? null + const text = await retried.text() + if (!text) { + return null as unknown as T + } + try { + return JSON.parse(text) as T + } catch { + return text as unknown as T + } } // Refresh failed — notify the app to redirect to login _unauthorizedHandler?.() @@ -130,6 +211,22 @@ export class BaseApiClient { } } + /** Whether the current access token should be refreshed before the next request. */ + private _shouldPreemptivelyRefresh(): boolean { + const provider = _preEmptiveRefreshProvider + if (!provider) return false + try { + return provider() + } catch { + return false + } + } + + private async _silentRefreshForPreEmption(): Promise { + if (!_refreshProvider) return + await _refreshProvider() + } + /** Low-level fetch with headers + URL resolved. */ private async _send(path: string, options: RequestInit): Promise { const effectiveUrl = this._resolveUrl(path) @@ -137,6 +234,23 @@ export class BaseApiClient { return fetch(effectiveUrl, { ...options, headers }) } + /** Low-level fetch with caller-supplied headers (overrides the default Authorization). */ + private async _sendWithHeaders( + path: string, + options: RequestInit, + headers: Record, + ): Promise { + const effectiveUrl = this._resolveUrl(path) + const merged: Record = { + ...(options.headers as Record | undefined), + ...headers, + } + if (!(options.body instanceof FormData)) { + merged['Content-Type'] = 'application/json' + } + return fetch(effectiveUrl, { ...options, headers: merged }) + } + private async _buildError(response: Response): Promise { const error: IApiError = { status: response.status, diff --git a/src/agentkit/server/frontend/src/components/admin/UserSessionsPanel.vue b/src/agentkit/server/frontend/src/components/admin/UserSessionsPanel.vue new file mode 100644 index 0000000..c6aad09 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/admin/UserSessionsPanel.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/settings/ActiveSessionsPanel.vue b/src/agentkit/server/frontend/src/components/settings/ActiveSessionsPanel.vue new file mode 100644 index 0000000..953c2e8 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/settings/ActiveSessionsPanel.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/settings/ChangePasswordPanel.vue b/src/agentkit/server/frontend/src/components/settings/ChangePasswordPanel.vue new file mode 100644 index 0000000..d2ef7ab --- /dev/null +++ b/src/agentkit/server/frontend/src/components/settings/ChangePasswordPanel.vue @@ -0,0 +1,159 @@ + + + + +