chore(F.4e): remove env-based site loading, unify OAuth Clients, update docs

- Remove env-based site discovery from SiteManager and ProjectManager
- Unify admin and user OAuth Clients into single role-based page
- Update all documentation to reflect DB-based site management
- Clean up env.example, README, ARCHITECTURE docs
- Improve Projects page empty state with link to My Sites
- Remove obsolete env discovery tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 00:24:33 +02:00
parent 81417b552f
commit 3815f5a6d5
23 changed files with 202 additions and 782 deletions

View File

@@ -10,12 +10,12 @@ import json
import logging
import os
import secrets
import bcrypt
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
import bcrypt
logger = logging.getLogger(__name__)

View File

@@ -850,12 +850,9 @@ async def get_all_projects(
is_admin = False
current_user_id = None
if user_session:
if (
hasattr(user_session, "user_type")
and user_session.user_type == "master"
or isinstance(user_session, dict)
and user_session.get("role") == "admin"
):
if hasattr(user_session, "user_type") and user_session.user_type == "master":
is_admin = True
elif isinstance(user_session, dict) and user_session.get("role") == "admin":
is_admin = True
elif isinstance(user_session, dict) and "user_id" in user_session:
current_user_id = user_session["user_id"]
@@ -1512,6 +1509,7 @@ async def get_oauth_clients_data() -> dict:
"grant_types": client.grant_types,
"allowed_scopes": client.allowed_scopes,
"created_at": client.created_at.isoformat() if client.created_at else "",
"owner_user_id": getattr(client, "owner_user_id", None),
}
)
@@ -1528,10 +1526,26 @@ async def get_oauth_clients_data() -> dict:
async def dashboard_oauth_clients_list(request: Request) -> Response:
"""Render OAuth clients list page (admin only)."""
session, redirect = _require_admin_session(request)
if redirect:
return redirect
"""Render OAuth clients list page (admin and user)."""
# Try admin session first, then user session
auth = get_dashboard_auth()
session = None
is_admin = False
user_id = None
admin_session = auth.get_session_from_request(request)
if admin_session and is_admin_session(admin_session):
session = admin_session
is_admin = True
else:
user_session = auth.get_user_session_from_request(request)
if user_session:
session = user_session
is_admin = is_admin_session(user_session)
user_id = user_session.get("user_id")
if not session:
return RedirectResponse(url="/auth/login", status_code=303)
# Get language
accept_language = request.headers.get("accept-language")
@@ -1539,8 +1553,13 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
lang = detect_language(accept_language, query_lang)
t = get_translations(lang)
# Get clients data
# Get clients data — admin sees all, user sees own
clients_data = await get_oauth_clients_data()
if not is_admin and user_id:
clients_data["clients"] = [
c for c in clients_data["clients"] if c.get("owner_user_id") == user_id
]
clients_data["total_count"] = len(clients_data["clients"])
return templates.TemplateResponse(
request,
@@ -1552,15 +1571,26 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
"clients": clients_data["clients"],
"total_count": clients_data["total_count"],
"current_page": "oauth_clients",
"is_admin": is_admin,
},
)
async def dashboard_oauth_clients_create(request: Request) -> Response:
"""API endpoint to create OAuth client (admin only)."""
session, redirect = _require_admin_session(request)
if redirect:
return JSONResponse({"error": "Admin access required"}, status_code=403)
"""API endpoint to create OAuth client (admin and user)."""
# Accept both admin and user sessions
auth = get_dashboard_auth()
owner_user_id = None
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
if admin_session and is_admin_session(admin_session):
pass # Admin — no owner_user_id
elif user_session:
owner_user_id = user_session.get("user_id")
else:
return JSONResponse({"error": "Authentication required"}, status_code=403)
try:
data = await request.json()
@@ -1569,7 +1599,7 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
redirect_uris = data.get("redirect_uris") or []
if not redirect_uris and data.get("redirect_uri"):
redirect_uris = [data.get("redirect_uri")]
scopes = data.get("scopes", ["read"])
scopes = data.get("scopes", ["read", "write", "admin"])
if not client_name or not redirect_uris:
return JSONResponse({"error": "Missing required fields"}, status_code=400)
@@ -1578,16 +1608,23 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
client_registry = get_client_registry()
client_id, client_secret = client_registry.create_client(
client_name=client_name, redirect_uris=redirect_uris, allowed_scopes=scopes
)
create_kwargs = {
"client_name": client_name,
"redirect_uris": redirect_uris,
"allowed_scopes": scopes,
}
if owner_user_id:
create_kwargs["owner_user_id"] = owner_user_id
client_id, client_secret = client_registry.create_client(**create_kwargs)
# Log the action
from core.audit_log import get_audit_logger
audit_logger = get_audit_logger()
audit_logger.log_system_event(
event=f"OAuth client created: {client_name}", details={"client_id": client_id}
event=f"OAuth client created: {client_name}",
details={"client_id": client_id, "owner_user_id": owner_user_id},
)
return JSONResponse(
@@ -1604,10 +1641,22 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
async def dashboard_oauth_clients_delete(request: Request) -> Response:
"""API endpoint to delete OAuth client (admin only)."""
session, redirect = _require_admin_session(request)
if redirect:
return JSONResponse({"error": "Admin access required"}, status_code=403)
"""API endpoint to delete OAuth client (admin and user)."""
# Accept both admin and user sessions
auth = get_dashboard_auth()
is_admin_user = False
user_id = None
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
if admin_session and is_admin_session(admin_session):
is_admin_user = True
elif user_session:
user_id = user_session.get("user_id")
is_admin_user = is_admin_session(user_session)
else:
return JSONResponse({"error": "Authentication required"}, status_code=403)
try:
client_id = request.path_params.get("client_id", "")
@@ -1616,6 +1665,14 @@ async def dashboard_oauth_clients_delete(request: Request) -> Response:
client_registry = get_client_registry()
# Non-admin users can only delete their own clients
if not is_admin_user and user_id:
client = client_registry.get_client(client_id)
if not client:
return JSONResponse({"error": "Client not found"}, status_code=404)
if getattr(client, "owner_user_id", None) != user_id:
return JSONResponse({"error": "Access denied"}, status_code=403)
success = client_registry.delete_client(client_id)
if success:
@@ -3131,107 +3188,18 @@ async def api_get_config(request: Request) -> Response:
async def dashboard_user_oauth_clients_list(request: Request) -> Response:
"""GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients."""
user_session, redirect = _require_user_session(request)
if redirect:
return redirect
accept_language = request.headers.get("accept-language")
query_lang = request.query_params.get("lang")
lang = detect_language(accept_language, query_lang)
t = get_translations(lang)
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
user_id = user_session["user_id"]
user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id]
return templates.TemplateResponse(
request,
"dashboard/user-oauth-clients.html",
{
"lang": lang,
"t": t,
"session": user_session,
"clients": user_clients,
"current_page": "connect",
},
)
"""GET /dashboard/connect/oauth-clients — Redirect to unified OAuth clients page."""
return RedirectResponse(url="/dashboard/oauth-clients", status_code=303)
async def dashboard_user_oauth_clients_create(request: Request) -> Response:
"""POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
client_name = body.get("client_name", "").strip()
redirect_uris_raw = body.get("redirect_uris", "")
scopes = body.get("scopes", ["read", "write", "admin"])
if not client_name:
return JSONResponse({"error": "Client name required"}, status_code=400)
if isinstance(redirect_uris_raw, str):
redirect_uris = [u.strip() for u in redirect_uris_raw.splitlines() if u.strip()]
else:
redirect_uris = [u.strip() for u in redirect_uris_raw if u.strip()]
if not redirect_uris:
return JSONResponse({"error": "At least one redirect URI required"}, status_code=400)
scope_str = " ".join(scopes) if isinstance(scopes, list) else scopes
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
client_id, client_secret = registry.create_client(
client_name=client_name,
redirect_uris=redirect_uris,
allowed_scopes=scope_str.split() if isinstance(scope_str, str) else scopes,
owner_user_id=user_session["user_id"],
)
client = registry.get_client(client_id)
return JSONResponse(
{
"client_id": client_id,
"client_secret": client_secret,
"client_name": client.client_name,
"redirect_uris": client.redirect_uris,
"scope": client.scope,
}
)
"""POST /api/dashboard/user-oauth-clients/create — Forwards to unified create endpoint."""
return await dashboard_oauth_clients_create(request)
async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
"""DELETE /api/dashboard/user-oauth-clients/{client_id}Delete user's own OAuth client."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
client_id = request.path_params.get("client_id", "")
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
client = registry.get_client(client_id)
if not client:
return JSONResponse({"error": "Client not found"}, status_code=404)
# Only allow deleting own clients
if client.owner_user_id != user_session["user_id"]:
return JSONResponse({"error": "Access denied"}, status_code=403)
registry.delete_client(client_id)
return JSONResponse({"success": True})
"""DELETE /api/dashboard/user-oauth-clients/{client_id}Forwards to unified delete endpoint."""
return await dashboard_oauth_clients_delete(request)
async def get_service_page_data(plugin_type: str) -> dict | None:

View File

@@ -1,33 +1,23 @@
"""
Project Manager
Project Manager (Legacy)
Discovers and manages project instances from environment variables.
Handles plugin lifecycle and tool registration.
Retained for backward compatibility with HealthMonitor.
Sites are now managed via the web dashboard (DB-based).
"""
import logging
import os
import re
from typing import Any
from plugins import BasePlugin, registry
from plugins import BasePlugin
logger = logging.getLogger(__name__)
class ProjectManager:
"""
Manage multiple project instances.
Legacy project manager — retained for HealthMonitor compatibility.
Projects are discovered from environment variables:
- {PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}
Example:
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx
WORDPRESS_SITE2_URL=https://other.com
...
Sites are now managed via the web dashboard and stored in SQLite.
"""
def __init__(self):
@@ -35,115 +25,6 @@ class ProjectManager:
self.projects: dict[str, BasePlugin] = {}
self.logger = logging.getLogger("ProjectManager")
def discover_projects(self) -> None:
"""
Discover projects from environment variables.
Scans environment for project configurations and creates
plugin instances.
"""
self.logger.info("Starting project discovery...")
# Get all registered plugin types
plugin_types = registry.get_registered_types()
for plugin_type in plugin_types:
self._discover_plugin_type(plugin_type)
self.logger.info(f"Discovery complete. Found {len(self.projects)} projects.")
def _discover_plugin_type(self, plugin_type: str) -> None:
"""
Discover all projects of a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
"""
prefix = plugin_type.upper() + "_"
# Build list of longer prefixes from other plugin types to avoid collisions.
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
plugin_types = registry.get_registered_types()
longer_prefixes = [
pt.upper() + "_"
for pt in plugin_types
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
]
# Find all project IDs for this plugin type
project_ids = set()
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
for env_key in os.environ.keys():
# Skip env vars that belong to a more specific plugin type
if any(env_key.startswith(lp) for lp in longer_prefixes):
continue
match = env_pattern.match(env_key)
if match:
project_id = match.group(1).lower()
project_ids.add(project_id)
# Create plugin instance for each project
for project_id in project_ids:
try:
config = self._load_project_config(plugin_type, project_id)
if config:
self._create_project_instance(plugin_type, project_id, config)
except Exception as e:
self.logger.debug(f"Legacy ProjectManager: skipped {plugin_type}/{project_id}: {e}")
def _load_project_config(self, plugin_type: str, project_id: str) -> dict[str, Any] | None:
"""
Load configuration for a project from environment.
Args:
plugin_type: Plugin type
project_id: Project ID
Returns:
Dict with configuration or None if incomplete
"""
prefix = f"{plugin_type.upper()}_{project_id.upper()}_"
config = {}
# Collect all config keys for this project
for env_key, env_value in os.environ.items():
if env_key.startswith(prefix):
# Extract config key (everything after prefix)
config_key = env_key[len(prefix) :].lower()
config[config_key] = env_value
if not config:
return None
self.logger.debug(f"Loaded config for {plugin_type}/{project_id}: {list(config.keys())}")
return config
def _create_project_instance(
self, plugin_type: str, project_id: str, config: dict[str, Any]
) -> None:
"""
Create a plugin instance for a project.
Args:
plugin_type: Plugin type
project_id: Project ID
config: Project configuration
"""
try:
# Create plugin instance
plugin = registry.create_instance(plugin_type, project_id, config)
# Store with full identifier
full_id = f"{plugin_type}_{project_id}"
self.projects[full_id] = plugin
self.logger.info(f"Created project: {full_id}")
except Exception as e:
raise Exception(f"Failed to instantiate {plugin_type}/{project_id}: {e}")
def get_project(self, full_id: str) -> BasePlugin | None:
"""
Get a project plugin instance.
@@ -255,5 +136,4 @@ def get_project_manager() -> ProjectManager:
global _project_manager
if _project_manager is None:
_project_manager = ProjectManager()
_project_manager.discover_projects()
return _project_manager

View File

@@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management
Manages site configurations with Pydantic validation.
Part of Option B clean architecture refactoring.
Discovers sites from environment variables:
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
Example:
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx
WORDPRESS_SITE2_ALIAS=myblog
Sites are managed via the web dashboard and stored in SQLite (DB-based).
The SiteManager provides registration and lookup infrastructure for
plugin tool generation and endpoint routing.
"""
import logging
import os
import re
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
@@ -111,7 +103,8 @@ class SiteManager:
"""
Manage site configurations with type safety.
Discovers, validates, and provides access to site configurations.
Provides registration and lookup of site configurations.
Sites are registered programmatically (e.g., from database) via register_site().
Attributes:
sites: Dictionary mapping plugin_type to site configurations
@@ -120,7 +113,7 @@ class SiteManager:
Examples:
>>> manager = SiteManager()
>>> manager.discover_sites(['wordpress', 'gitea'])
>>> manager.register_site(config)
>>> config = manager.get_site_config('wordpress', 'myblog')
>>> sites = manager.list_sites('wordpress')
"""
@@ -136,167 +129,6 @@ class SiteManager:
self.logger = logging.getLogger("SiteManager")
self.logger.info("SiteManager initialized")
def discover_sites(self, plugin_types: list[str]) -> int:
"""
Discover sites from environment variables.
Scans environment for site configurations and registers them.
Args:
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
Returns:
Number of sites discovered
Examples:
>>> count = manager.discover_sites(['wordpress', 'gitea'])
>>> print(f"Discovered {count} sites")
"""
self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}")
total_discovered = 0
for plugin_type in plugin_types:
count = self._discover_plugin_sites(plugin_type)
total_discovered += count
self.logger.info(
f"Discovery complete. Found {total_discovered} sites "
f"across {len(plugin_types)} plugin types."
)
return total_discovered
# Reserved words that should NOT be interpreted as site IDs
RESERVED_SITE_WORDS = {
"limit",
"rate",
"config",
"debug",
"log",
"level",
"mode",
"timeout",
"retry",
"max",
"min",
"default",
"global",
"enabled",
"disabled",
"host",
"port",
"path",
"key",
"secret",
"token",
"advanced",
"basic",
"simple",
"pro",
"premium",
"standard",
}
def _discover_plugin_sites(self, plugin_type: str) -> int:
"""
Discover all sites for a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
Returns:
Number of sites discovered for this plugin type
Examples:
>>> count = manager._discover_plugin_sites('wordpress')
"""
prefix = plugin_type.upper() + "_"
# Build list of longer prefixes from other plugin types to avoid collisions.
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
from plugins import registry as plugin_registry
all_plugin_types = plugin_registry.get_registered_types()
longer_prefixes = [
pt.upper() + "_"
for pt in all_plugin_types
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
]
# Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc.
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
# Find all unique site IDs
site_ids = set()
for env_key in os.environ.keys():
# Skip env vars that belong to a more specific plugin type
if any(env_key.startswith(lp) for lp in longer_prefixes):
continue
match = env_pattern.match(env_key)
if match:
site_id = match.group(1).lower()
# Skip reserved words that are not real site IDs
if site_id not in self.RESERVED_SITE_WORDS:
site_ids.add(site_id)
# Load configuration for each site
discovered_count = 0
for site_id in site_ids:
try:
config = self._load_site_config(plugin_type, site_id)
if config:
self.register_site(config)
discovered_count += 1
except Exception as e:
self.logger.error(
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
)
return discovered_count
def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None:
"""
Load configuration for a site from environment.
Args:
plugin_type: Plugin type
site_id: Site ID
Returns:
SiteConfig if successful, None if incomplete
Examples:
>>> config = manager._load_site_config('wordpress', 'site1')
"""
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
config_data = {"site_id": site_id, "plugin_type": plugin_type}
# Collect all config keys for this site
for env_key, env_value in os.environ.items():
if env_key.startswith(prefix):
# Extract config key (everything after prefix)
config_key = env_key[len(prefix) :].lower()
config_data[config_key] = env_value
# Must have at least some configuration beyond site_id and plugin_type
if len(config_data) <= 2:
return None
try:
# Create and validate SiteConfig
config = SiteConfig(**config_data)
self.logger.debug(
f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}"
)
return config
except Exception as e:
self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True)
return None
def register_site(self, config: SiteConfig) -> None:
"""
Register a site configuration.
@@ -356,7 +188,7 @@ class SiteManager:
# SECURITY: Don't reveal available plugin types in multi-tenant environment
raise ValueError(
f"No sites configured for plugin type: {plugin_type}. "
f"Please check your environment variables."
f"Please add a site via the dashboard."
)
# Try direct lookup
@@ -373,7 +205,7 @@ class SiteManager:
)
raise ValueError(
f"Site '{site}' not configured for {plugin_type}. "
f"Please verify the site alias/ID and check environment variables."
f"Please verify the site alias/ID in the dashboard."
)
def list_sites(self, plugin_type: str) -> list[str]:
@@ -545,7 +377,7 @@ def get_site_manager() -> SiteManager:
Examples:
>>> manager = get_site_manager()
>>> manager.discover_sites(['wordpress'])
>>> manager.register_site(config)
"""
global _site_manager
if _site_manager is None:

View File

@@ -138,6 +138,9 @@
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
] %}
{# ── Admin-only nav items ── #}
@@ -146,9 +149,6 @@
'/dashboard/projects'),
('api_keys', t.api_keys, 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/api-keys'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2
2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
'/dashboard/audit-logs'),

View File

@@ -159,10 +159,10 @@
<p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>

View File

@@ -228,6 +228,14 @@
<p class="text-gray-500 text-sm mt-2">
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
</p>
{% else %}
<p class="text-gray-500 text-sm mt-2">
{% if lang == 'fa' %}
سایت‌ها از طریق <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-primary-400 hover:underline">صفحه سایت‌ها</a> مدیریت می‌شوند.
{% else %}
Sites are managed via the <a href="/dashboard/sites" class="text-primary-400 hover:underline">My Sites</a> page.
{% endif %}
</p>
{% endif %}
</td>
</tr>