refactor: unified google vein, prefixed module loading, cfg separation
- Unified google vein with OAuth + Sheets API - Prefixed vein module loading (vein_google) to avoid pip package shadowing - Preload pip packages before vein loading - Added common/auth framework - Rebranded sbwrapper from Pawprint to Soleprint - Removed cfg/ from history (now separate repo) - Keep cfg/standalone/ as sample configuration - gitignore cfg/amar/ and cfg/dlt/ (private configs)
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -12,5 +12,7 @@ venv/
|
|||||||
# Generated runnable instance (entirely gitignored - regenerate with build.py)
|
# Generated runnable instance (entirely gitignored - regenerate with build.py)
|
||||||
gen/
|
gen/
|
||||||
|
|
||||||
# Database dumps (sensitive data)
|
# Room configurations (separate repo - contains credentials and room-specific data)
|
||||||
cfg/*/dumps/*.sql
|
# Keep cfg/standalone/ as sample, ignore actual rooms
|
||||||
|
cfg/amar/
|
||||||
|
cfg/dlt/
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Soleprint Pipeline
|
|
||||||
|
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: notify
|
|
||||||
image: alpine
|
|
||||||
commands:
|
|
||||||
- echo "=== Soleprint ==="
|
|
||||||
- "echo Branch: $CI_COMMIT_BRANCH"
|
|
||||||
- "echo Commit: $CI_COMMIT_SHA"
|
|
||||||
- "echo Build locally: ./ctrl/deploy-domains.sh standalone --build"
|
|
||||||
4
build.py
4
build.py
@@ -294,6 +294,10 @@ def build_soleprint(output_dir: Path, room: str):
|
|||||||
if source.exists():
|
if source.exists():
|
||||||
copy_path(source, output_dir / system)
|
copy_path(source, output_dir / system)
|
||||||
|
|
||||||
|
# Common modules (auth, etc)
|
||||||
|
if (soleprint / "common").exists():
|
||||||
|
copy_path(soleprint / "common", output_dir / "common")
|
||||||
|
|
||||||
# Room config (includes merging room-specific artery/atlas/station)
|
# Room config (includes merging room-specific artery/atlas/station)
|
||||||
copy_cfg(output_dir, room)
|
copy_cfg(output_dir, room)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"name": "google",
|
"name": "google",
|
||||||
"slug": "google",
|
"slug": "google",
|
||||||
"title": "Google",
|
"title": "Google",
|
||||||
"status": "planned",
|
"status": "building",
|
||||||
"system": "artery"
|
"system": "artery"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -762,12 +762,116 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Placeholder tabs -->
|
<!-- Google API Tab -->
|
||||||
<section id="tab-google" class="tab-content">
|
<section id="tab-google_api" class="tab-content">
|
||||||
<h2>Google</h2>
|
<h2>Google Sheets</h2>
|
||||||
<p>Google connector. Planned.</p>
|
|
||||||
|
<!-- Auth Status -->
|
||||||
|
<div id="google-auth-status" class="api-form">
|
||||||
|
<div id="google-not-connected">
|
||||||
|
<p style="color: #a3a3a3; margin: 0 0 1rem 0">
|
||||||
|
Connect your Google account to access Sheets.
|
||||||
|
</p>
|
||||||
|
<div class="api-controls" style="margin-top: 0">
|
||||||
|
<button id="btn-google-connect">
|
||||||
|
Connect Google Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="google-connected" style="display: none">
|
||||||
|
<p style="color: #4ade80; margin: 0 0 1rem 0">
|
||||||
|
✓ Connected to Google
|
||||||
|
</p>
|
||||||
|
<div class="api-controls" style="margin-top: 0">
|
||||||
|
<button id="btn-google-disconnect" class="tab-button">
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sheets Form (shown when connected) -->
|
||||||
|
<div id="google-sheets-form" style="display: none">
|
||||||
|
<div class="api-form">
|
||||||
|
<label for="spreadsheet-id">Spreadsheet ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="spreadsheet-id"
|
||||||
|
placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"
|
||||||
|
/>
|
||||||
|
<p style="color: #666; font-size: 0.8rem; margin: 0.5rem 0">
|
||||||
|
Find this in the spreadsheet URL:
|
||||||
|
docs.google.com/spreadsheets/d/<strong>SPREADSHEET_ID</strong>/edit
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="api-controls" style="margin-top: 1rem">
|
||||||
|
<button id="btn-list-sheets" class="tab-button">
|
||||||
|
List Sheets
|
||||||
|
</button>
|
||||||
|
<button id="btn-get-metadata" class="tab-button">
|
||||||
|
Get Metadata
|
||||||
|
</button>
|
||||||
|
<label style="margin-left: auto">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="google-text-mode"
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
Text output
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-form" style="margin-top: 1.5rem">
|
||||||
|
<label for="sheet-range">Range (A1 notation)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="sheet-range"
|
||||||
|
placeholder="Sheet1!A1:D10"
|
||||||
|
/>
|
||||||
|
<div class="api-controls">
|
||||||
|
<button id="btn-get-values">Get Values</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output -->
|
||||||
|
<div id="google-output-container" class="output-container">
|
||||||
|
<div id="google-output" class="output-area scrollable"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top: 2rem">Endpoints</h2>
|
||||||
|
<ul class="endpoints">
|
||||||
|
<li>
|
||||||
|
<code>/artery/google_api/oauth/start</code>
|
||||||
|
<span class="desc">Start OAuth flow</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/artery/google_api/oauth/status</code>
|
||||||
|
<span class="desc">Check connection status</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/artery/google_api/spreadsheets/{id}</code>
|
||||||
|
<span class="desc">Spreadsheet metadata</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/artery/google_api/spreadsheets/{id}/sheets</code>
|
||||||
|
<span class="desc">List sheets</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code
|
||||||
|
>/artery/google_api/spreadsheets/{id}/values?range=...</code
|
||||||
|
>
|
||||||
|
<span class="desc">Get cell values</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 1rem; font-size: 0.85rem; color: #666">
|
||||||
|
Add <code>?text=true</code> for LLM-friendly output.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Placeholder tabs -->
|
||||||
|
|
||||||
<section id="tab-maps" class="tab-content">
|
<section id="tab-maps" class="tab-content">
|
||||||
<h2>Maps</h2>
|
<h2>Maps</h2>
|
||||||
<p>Maps connector. Planned.</p>
|
<p>Maps connector. Planned.</p>
|
||||||
@@ -1487,6 +1591,223 @@
|
|||||||
showError(output, e.message);
|
showError(output, e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Google Tab
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const googleNotConnected = document.getElementById(
|
||||||
|
"google-not-connected",
|
||||||
|
);
|
||||||
|
const googleConnected = document.getElementById("google-connected");
|
||||||
|
const googleSheetsForm =
|
||||||
|
document.getElementById("google-sheets-form");
|
||||||
|
const googleOutput = document.getElementById("google-output");
|
||||||
|
const googleOutputContainer = document.getElementById(
|
||||||
|
"google-output-container",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function checkGoogleAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/artery/google_api/oauth/status");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
googleNotConnected.style.display = "none";
|
||||||
|
googleConnected.style.display = "block";
|
||||||
|
googleSheetsForm.style.display = "block";
|
||||||
|
} else {
|
||||||
|
googleNotConnected.style.display = "block";
|
||||||
|
googleConnected.style.display = "none";
|
||||||
|
googleSheetsForm.style.display = "none";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to check Google auth status:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check auth on page load if Google tab elements exist
|
||||||
|
if (googleNotConnected) {
|
||||||
|
checkGoogleAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check when switching to Google API tab
|
||||||
|
document
|
||||||
|
.querySelectorAll('.vein[data-tab="google_api"]')
|
||||||
|
.forEach((vein) => {
|
||||||
|
vein.addEventListener("click", () => {
|
||||||
|
checkGoogleAuth();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect button
|
||||||
|
document
|
||||||
|
.getElementById("btn-google-connect")
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
// Redirect to OAuth start, will come back to /artery after auth
|
||||||
|
window.location.href =
|
||||||
|
"/artery/google_api/oauth/start?redirect=/artery";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disconnect button
|
||||||
|
document
|
||||||
|
.getElementById("btn-google-disconnect")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
await fetch("/artery/google_api/oauth/logout");
|
||||||
|
checkGoogleAuth();
|
||||||
|
googleOutputContainer.classList.remove("visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Google output helpers
|
||||||
|
function showGoogleOutput(text, isError = false) {
|
||||||
|
googleOutput.textContent = text;
|
||||||
|
googleOutput.classList.toggle("error", isError);
|
||||||
|
googleOutputContainer.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
// List Sheets
|
||||||
|
document
|
||||||
|
.getElementById("btn-list-sheets")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
const spreadsheetId = document
|
||||||
|
.getElementById("spreadsheet-id")
|
||||||
|
.value.trim();
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
showGoogleOutput(
|
||||||
|
"Error: Please enter a Spreadsheet ID",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMode =
|
||||||
|
document.getElementById("google-text-mode").checked;
|
||||||
|
showGoogleOutput("Loading...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (textMode) params.set("text", "true");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/artery/google_api/spreadsheets/${spreadsheetId}/sheets?${params}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textMode) {
|
||||||
|
showGoogleOutput(await res.text());
|
||||||
|
} else {
|
||||||
|
showGoogleOutput(
|
||||||
|
JSON.stringify(await res.json(), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showGoogleOutput("Error: " + e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Metadata
|
||||||
|
document
|
||||||
|
.getElementById("btn-get-metadata")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
const spreadsheetId = document
|
||||||
|
.getElementById("spreadsheet-id")
|
||||||
|
.value.trim();
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
showGoogleOutput(
|
||||||
|
"Error: Please enter a Spreadsheet ID",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMode =
|
||||||
|
document.getElementById("google-text-mode").checked;
|
||||||
|
showGoogleOutput("Loading...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (textMode) params.set("text", "true");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/artery/google_api/spreadsheets/${spreadsheetId}?${params}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textMode) {
|
||||||
|
showGoogleOutput(await res.text());
|
||||||
|
} else {
|
||||||
|
showGoogleOutput(
|
||||||
|
JSON.stringify(await res.json(), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showGoogleOutput("Error: " + e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Values
|
||||||
|
document
|
||||||
|
.getElementById("btn-get-values")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
const spreadsheetId = document
|
||||||
|
.getElementById("spreadsheet-id")
|
||||||
|
.value.trim();
|
||||||
|
const range = document
|
||||||
|
.getElementById("sheet-range")
|
||||||
|
.value.trim();
|
||||||
|
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
showGoogleOutput(
|
||||||
|
"Error: Please enter a Spreadsheet ID",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!range) {
|
||||||
|
showGoogleOutput(
|
||||||
|
"Error: Please enter a Range (e.g., Sheet1!A1:D10)",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMode =
|
||||||
|
document.getElementById("google-text-mode").checked;
|
||||||
|
showGoogleOutput("Loading...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("range", range);
|
||||||
|
if (textMode) params.set("text", "true");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/artery/google_api/spreadsheets/${spreadsheetId}/values?${params}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textMode) {
|
||||||
|
showGoogleOutput(await res.text());
|
||||||
|
} else {
|
||||||
|
showGoogleOutput(
|
||||||
|
JSON.stringify(await res.json(), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showGoogleOutput("Error: " + e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,22 +2,19 @@
|
|||||||
API routes for Google vein.
|
API routes for Google vein.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
from fastapi.responses import PlainTextResponse, RedirectResponse
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from core.oauth import GoogleOAuth
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from core.sheets import GoogleSheetsClient, GoogleSheetsError
|
from fastapi.responses import PlainTextResponse, RedirectResponse
|
||||||
from models.spreadsheet import SpreadsheetMetadata, SheetValues
|
|
||||||
from models.formatter import format_spreadsheet_metadata, format_sheet_values
|
|
||||||
|
|
||||||
# Import from parent vein module
|
# Import shared OAuth utilities from veins parent
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
vein_path = Path(__file__).parent.parent.parent
|
|
||||||
sys.path.insert(0, str(vein_path))
|
|
||||||
from oauth import TokenStorage
|
from oauth import TokenStorage
|
||||||
|
|
||||||
|
from ..core.oauth import GoogleOAuth
|
||||||
|
from ..core.sheets import GoogleSheetsClient, GoogleSheetsError
|
||||||
|
from ..models.formatter import format_sheet_values, format_spreadsheet_metadata
|
||||||
|
from ..models.spreadsheet import SheetValues, SpreadsheetMetadata
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# OAuth client and token storage
|
# OAuth client and token storage
|
||||||
@@ -71,18 +68,30 @@ def _maybe_text(data, text: bool, formatter):
|
|||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
"""Check if user is authenticated."""
|
"""Check vein health and configuration status."""
|
||||||
|
from ..core.config import settings
|
||||||
|
|
||||||
|
configured = bool(settings.google_client_id and settings.google_client_secret)
|
||||||
|
if not configured:
|
||||||
|
return {
|
||||||
|
"status": "not_configured",
|
||||||
|
"configured": False,
|
||||||
|
"message": "Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tokens = token_storage.load_tokens(DEFAULT_USER_ID)
|
tokens = token_storage.load_tokens(DEFAULT_USER_ID)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return {
|
return {
|
||||||
"status": "not_authenticated",
|
"status": "not_authenticated",
|
||||||
"message": "Visit /google/oauth/start to login",
|
"configured": True,
|
||||||
|
"message": "Visit /artery/google/oauth/start to login",
|
||||||
}
|
}
|
||||||
|
|
||||||
expired = token_storage.is_expired(tokens)
|
expired = token_storage.is_expired(tokens)
|
||||||
return {
|
return {
|
||||||
"status": "ok" if not expired else "token_expired",
|
"status": "ok" if not expired else "token_expired",
|
||||||
|
"configured": True,
|
||||||
"has_refresh_token": "refresh_token" in tokens,
|
"has_refresh_token": "refresh_token" in tokens,
|
||||||
"user": DEFAULT_USER_ID,
|
"user": DEFAULT_USER_ID,
|
||||||
}
|
}
|
||||||
@@ -91,9 +100,25 @@ async def health():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/oauth/start")
|
@router.get("/oauth/start")
|
||||||
async def start_oauth(state: Optional[str] = None):
|
async def start_oauth(
|
||||||
"""Start OAuth flow - redirect to Google authorization."""
|
state: Optional[str] = None,
|
||||||
auth_url = oauth_client.get_authorization_url(state=state)
|
hd: Optional[str] = None,
|
||||||
|
redirect: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Start OAuth flow - redirect to Google authorization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: CSRF token (passed through to callback)
|
||||||
|
hd: Hosted domain hint (e.g., 'company.com') to pre-select account
|
||||||
|
redirect: URL to redirect after successful auth
|
||||||
|
"""
|
||||||
|
# Encode redirect in state if provided
|
||||||
|
full_state = state or ""
|
||||||
|
if redirect:
|
||||||
|
full_state = f"{full_state}|{redirect}" if full_state else redirect
|
||||||
|
|
||||||
|
auth_url = oauth_client.get_authorization_url(state=full_state, hd=hd)
|
||||||
return RedirectResponse(auth_url)
|
return RedirectResponse(auth_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -122,6 +147,23 @@ async def oauth_callback(
|
|||||||
raise HTTPException(500, f"Failed to exchange code: {e}")
|
raise HTTPException(500, f"Failed to exchange code: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oauth/userinfo")
|
||||||
|
async def get_userinfo(
|
||||||
|
code: str = Query(..., description="Authorization code from callback"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Exchange code and return user info (identity flow).
|
||||||
|
|
||||||
|
Used by common/auth for login - returns user identity without storing tokens.
|
||||||
|
For API access flows, use /oauth/callback instead.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_info = oauth_client.exchange_code_for_user(code)
|
||||||
|
return user_info
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Failed to get user info: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/oauth/logout")
|
@router.get("/oauth/logout")
|
||||||
async def logout():
|
async def logout():
|
||||||
"""Clear stored tokens."""
|
"""Clear stored tokens."""
|
||||||
|
|||||||
@@ -3,21 +3,37 @@ Google OAuth2 configuration loaded from .env file.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||||
|
|
||||||
|
# OpenID scopes for identity verification
|
||||||
|
IDENTITY_SCOPES = [
|
||||||
|
"openid",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
]
|
||||||
|
|
||||||
|
# API scopes for data access (Sheets, Drive)
|
||||||
|
API_SCOPES = [
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
||||||
|
"https://www.googleapis.com/auth/drive.readonly",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class GoogleConfig(BaseSettings):
|
class GoogleConfig(BaseSettings):
|
||||||
google_client_id: str
|
google_client_id: str = ""
|
||||||
google_client_secret: str
|
google_client_secret: str = ""
|
||||||
google_redirect_uri: str # e.g., https://artery.mcrn.ar/google/oauth/callback
|
google_redirect_uri: str = "http://localhost:12000/artery/google/oauth/callback"
|
||||||
google_scopes: str = "https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly"
|
# Default to identity-only scopes; add API scopes when needed
|
||||||
|
google_scopes: str = " ".join(IDENTITY_SCOPES)
|
||||||
api_port: int = 8003
|
api_port: int = 8003
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"env_file": ENV_FILE,
|
"env_file": ENV_FILE,
|
||||||
"env_file_encoding": "utf-8",
|
"env_file_encoding": "utf-8",
|
||||||
|
"extra": "ignore",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
Google OAuth2 flow implementation.
|
Google OAuth2 flow implementation.
|
||||||
|
|
||||||
Isolated OAuth2 client that can run without FastAPI.
|
Isolated OAuth2 client that can run without FastAPI.
|
||||||
|
Supports both identity (OpenID) and API access flows.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
|
from google.oauth2 import id_token
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from google_auth_oauthlib.flow import Flow
|
from google_auth_oauthlib.flow import Flow
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -52,21 +57,29 @@ class GoogleOAuth:
|
|||||||
)
|
)
|
||||||
return flow
|
return flow
|
||||||
|
|
||||||
def get_authorization_url(self, state: Optional[str] = None) -> str:
|
def get_authorization_url(
|
||||||
|
self, state: Optional[str] = None, hd: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate OAuth2 authorization URL.
|
Generate OAuth2 authorization URL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
state: Optional state parameter for CSRF protection
|
state: Optional state parameter for CSRF protection
|
||||||
|
hd: Hosted domain hint (e.g., 'company.com') to pre-select account
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
URL to redirect user for Google authorization
|
URL to redirect user for Google authorization
|
||||||
"""
|
"""
|
||||||
flow = self._create_flow()
|
flow = self._create_flow()
|
||||||
|
extra_params = {}
|
||||||
|
if hd:
|
||||||
|
extra_params["hd"] = hd
|
||||||
|
|
||||||
auth_url, _ = flow.authorization_url(
|
auth_url, _ = flow.authorization_url(
|
||||||
access_type="offline", # Request refresh token
|
access_type="offline", # Request refresh token
|
||||||
include_granted_scopes="true",
|
include_granted_scopes="true",
|
||||||
state=state,
|
state=state,
|
||||||
|
**extra_params,
|
||||||
)
|
)
|
||||||
return auth_url
|
return auth_url
|
||||||
|
|
||||||
@@ -97,6 +110,42 @@ class GoogleOAuth:
|
|||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def exchange_code_for_user(self, code: str) -> dict:
|
||||||
|
"""
|
||||||
|
Exchange authorization code and return user identity info.
|
||||||
|
|
||||||
|
Used for identity/login flows (OpenID Connect).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Authorization code from callback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict containing:
|
||||||
|
- email
|
||||||
|
- name
|
||||||
|
- picture
|
||||||
|
- hd (hosted domain, if Google Workspace account)
|
||||||
|
"""
|
||||||
|
flow = self._create_flow()
|
||||||
|
flow.fetch_token(code=code)
|
||||||
|
|
||||||
|
credentials = flow.credentials
|
||||||
|
|
||||||
|
# Fetch user info from Google's userinfo endpoint
|
||||||
|
userinfo_response = requests.get(
|
||||||
|
"https://www.googleapis.com/oauth2/v3/userinfo",
|
||||||
|
headers={"Authorization": f"Bearer {credentials.token}"},
|
||||||
|
)
|
||||||
|
userinfo_response.raise_for_status()
|
||||||
|
userinfo = userinfo_response.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": userinfo.get("email"),
|
||||||
|
"name": userinfo.get("name"),
|
||||||
|
"picture": userinfo.get("picture"),
|
||||||
|
"hd": userinfo.get("hd"), # Hosted domain (Google Workspace)
|
||||||
|
}
|
||||||
|
|
||||||
def refresh_access_token(self, refresh_token: str) -> dict:
|
def refresh_access_token(self, refresh_token: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Refresh an expired access token.
|
Refresh an expired access token.
|
||||||
@@ -126,7 +175,9 @@ class GoogleOAuth:
|
|||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_credentials(self, access_token: str, refresh_token: Optional[str] = None) -> Credentials:
|
def get_credentials(
|
||||||
|
self, access_token: str, refresh_token: Optional[str] = None
|
||||||
|
) -> Credentials:
|
||||||
"""
|
"""
|
||||||
Create Google Credentials object from tokens.
|
Create Google Credentials object from tokens.
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,24 @@ Jira credentials loaded from .env file.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||||
|
|
||||||
|
|
||||||
class JiraConfig(BaseSettings):
|
class JiraConfig(BaseSettings):
|
||||||
jira_url: str
|
jira_url: str = "" # Required for use, optional for loading
|
||||||
jira_email: str | None = None # Optional: can be provided per-request via headers
|
jira_email: str | None = None # Optional: can be provided per-request via headers
|
||||||
jira_api_token: str | None = None # Optional: can be provided per-request via headers
|
jira_api_token: str | None = (
|
||||||
|
None # Optional: can be provided per-request via headers
|
||||||
|
)
|
||||||
api_port: int = 8001
|
api_port: int = 8001
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"env_file": ENV_FILE,
|
"env_file": ENV_FILE,
|
||||||
"env_file_encoding": "utf-8",
|
"env_file_encoding": "utf-8",
|
||||||
|
"extra": "ignore",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from datetime import datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .base import BaseVein, TClient, TCredentials
|
from base import BaseVein, TClient, TCredentials
|
||||||
|
|
||||||
|
|
||||||
class TokenStorage:
|
class TokenStorage:
|
||||||
|
|||||||
3
soleprint/common/__init__.py
Normal file
3
soleprint/common/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Common module - shared abstractions reusable across soleprint systems.
|
||||||
|
"""
|
||||||
10
soleprint/common/auth/__init__.py
Normal file
10
soleprint/common/auth/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Generic authentication framework for soleprint.
|
||||||
|
|
||||||
|
Provider-agnostic - delegates to configured provider vein (e.g., google_login).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config import AuthConfig, load_auth_config
|
||||||
|
from .session import get_current_user, require_auth
|
||||||
|
|
||||||
|
__all__ = ["AuthConfig", "load_auth_config", "get_current_user", "require_auth"]
|
||||||
43
soleprint/common/auth/config.py
Normal file
43
soleprint/common/auth/config.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Authentication configuration.
|
||||||
|
|
||||||
|
Generic config that works with any provider vein.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AuthConfig(BaseModel):
|
||||||
|
"""Authentication configuration for a room."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
provider: str = "google" # Vein name to use for auth
|
||||||
|
allowed_domains: list[str] = [] # Empty = allow any domain
|
||||||
|
session_secret: str = "" # Required if enabled, can be "ENV:VAR_NAME"
|
||||||
|
session_timeout_hours: int = 24
|
||||||
|
login_redirect: str = "/"
|
||||||
|
public_routes: list[str] = [
|
||||||
|
"/health",
|
||||||
|
"/auth/login",
|
||||||
|
"/auth/callback",
|
||||||
|
"/auth/logout",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_auth_config(config: dict) -> Optional[AuthConfig]:
|
||||||
|
"""
|
||||||
|
Load auth config from room config.json.
|
||||||
|
|
||||||
|
Returns None if auth is not enabled.
|
||||||
|
"""
|
||||||
|
auth_data = config.get("auth")
|
||||||
|
if not auth_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth_config = AuthConfig(**auth_data)
|
||||||
|
if not auth_config.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return auth_config
|
||||||
91
soleprint/common/auth/middleware.py
Normal file
91
soleprint/common/auth/middleware.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Authentication middleware for route protection.
|
||||||
|
|
||||||
|
Generic middleware, provider-agnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from .config import AuthConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""
|
||||||
|
Middleware that protects routes by requiring authentication.
|
||||||
|
|
||||||
|
- Public routes (configurable) are allowed without auth
|
||||||
|
- Unauthenticated browser requests redirect to /auth/login
|
||||||
|
- Unauthenticated API requests get 401 JSON response
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, auth_config: AuthConfig):
|
||||||
|
super().__init__(app)
|
||||||
|
self.config = auth_config
|
||||||
|
self.public_routes = set(auth_config.public_routes)
|
||||||
|
# Also allow static files and common paths
|
||||||
|
self.public_prefixes = ["/static", "/favicon", "/artery"]
|
||||||
|
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Check if route is public
|
||||||
|
if self._is_public(path):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Check session
|
||||||
|
session = request.session
|
||||||
|
user_email = session.get("user_email")
|
||||||
|
expires_at = session.get("expires_at")
|
||||||
|
|
||||||
|
if not user_email:
|
||||||
|
return self._unauthorized(request, "Not authenticated")
|
||||||
|
|
||||||
|
# Check expiry
|
||||||
|
if expires_at:
|
||||||
|
if datetime.fromisoformat(expires_at) < datetime.now():
|
||||||
|
session.clear()
|
||||||
|
return self._unauthorized(request, "Session expired")
|
||||||
|
|
||||||
|
# Check domain restriction
|
||||||
|
user_domain = session.get("domain")
|
||||||
|
if self.config.allowed_domains:
|
||||||
|
if not user_domain or user_domain not in self.config.allowed_domains:
|
||||||
|
session.clear()
|
||||||
|
return self._unauthorized(
|
||||||
|
request,
|
||||||
|
f"Access restricted to: {', '.join(self.config.allowed_domains)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach user to request state for downstream use
|
||||||
|
request.state.user = {
|
||||||
|
"email": user_email,
|
||||||
|
"name": session.get("user_name"),
|
||||||
|
"domain": user_domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
def _is_public(self, path: str) -> bool:
|
||||||
|
"""Check if path is public (no auth required)."""
|
||||||
|
if path in self.public_routes:
|
||||||
|
return True
|
||||||
|
for prefix in self.public_prefixes:
|
||||||
|
if path.startswith(prefix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _unauthorized(self, request, message: str):
|
||||||
|
"""Return appropriate unauthorized response."""
|
||||||
|
# API requests get JSON 401
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
if "application/json" in accept:
|
||||||
|
return JSONResponse({"error": message}, status_code=401)
|
||||||
|
|
||||||
|
# Browser requests redirect to login with return URL
|
||||||
|
next_url = str(request.url.path)
|
||||||
|
if request.url.query:
|
||||||
|
next_url += f"?{request.url.query}"
|
||||||
|
return RedirectResponse(url=f"/auth/login?next={next_url}")
|
||||||
170
soleprint/common/auth/routes.py
Normal file
170
soleprint/common/auth/routes.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
Authentication routes.
|
||||||
|
|
||||||
|
Generic routes that delegate to configured provider vein.
|
||||||
|
|
||||||
|
/auth/login - Start login flow (redirects to provider)
|
||||||
|
/auth/callback - Handle provider callback, create session
|
||||||
|
/auth/logout - Clear session
|
||||||
|
/auth/me - Get current user info
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from .config import AuthConfig
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Will be initialized by setup_auth() in run.py
|
||||||
|
auth_config: Optional[AuthConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_auth(config: AuthConfig):
|
||||||
|
"""
|
||||||
|
Initialize auth module with configuration.
|
||||||
|
|
||||||
|
Called by run.py when setting up authentication.
|
||||||
|
"""
|
||||||
|
global auth_config
|
||||||
|
auth_config = config
|
||||||
|
|
||||||
|
|
||||||
|
def _get_provider_base_url() -> str:
|
||||||
|
"""Get base URL for the configured provider vein."""
|
||||||
|
if not auth_config:
|
||||||
|
raise HTTPException(500, "Auth not configured")
|
||||||
|
# Provider is a vein name like "google_login"
|
||||||
|
return f"/artery/{auth_config.provider}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
async def login(request: Request, next: str = "/"):
|
||||||
|
"""
|
||||||
|
Start login flow.
|
||||||
|
|
||||||
|
Redirects to the configured provider vein's OAuth start endpoint.
|
||||||
|
"""
|
||||||
|
if not auth_config:
|
||||||
|
raise HTTPException(500, "Auth not configured")
|
||||||
|
|
||||||
|
# Generate CSRF state token
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
request.session["oauth_state"] = state
|
||||||
|
request.session["oauth_next"] = next
|
||||||
|
|
||||||
|
# Get domain hint from config (first allowed domain)
|
||||||
|
hd = auth_config.allowed_domains[0] if auth_config.allowed_domains else None
|
||||||
|
|
||||||
|
# Build provider OAuth URL
|
||||||
|
provider_url = _get_provider_base_url()
|
||||||
|
params = f"?state={state}"
|
||||||
|
if hd:
|
||||||
|
params += f"&hd={hd}"
|
||||||
|
|
||||||
|
# Redirect includes callback to our /auth/callback
|
||||||
|
return RedirectResponse(url=f"{provider_url}/oauth/start{params}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def callback(
|
||||||
|
request: Request,
|
||||||
|
code: Optional[str] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Handle OAuth callback.
|
||||||
|
|
||||||
|
Receives code from provider, exchanges for user info, creates session.
|
||||||
|
"""
|
||||||
|
if not auth_config:
|
||||||
|
raise HTTPException(500, "Auth not configured")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
raise HTTPException(400, f"OAuth error: {error}")
|
||||||
|
|
||||||
|
# Verify state
|
||||||
|
expected_state = request.session.get("oauth_state")
|
||||||
|
if not state or state != expected_state:
|
||||||
|
raise HTTPException(400, "Invalid state parameter")
|
||||||
|
|
||||||
|
# Call provider vein to exchange code for user info
|
||||||
|
provider_url = _get_provider_base_url()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Get base URL from request
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
response = await client.get(
|
||||||
|
f"{base_url}{provider_url}/oauth/userinfo",
|
||||||
|
params={"code": code},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(400, f"Provider error: {response.text}")
|
||||||
|
user_info = response.json()
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise HTTPException(500, f"Failed to contact provider: {e}")
|
||||||
|
|
||||||
|
# Verify domain if restricted
|
||||||
|
user_domain = user_info.get("hd")
|
||||||
|
if auth_config.allowed_domains:
|
||||||
|
if not user_domain or user_domain not in auth_config.allowed_domains:
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
f"Access restricted to: {', '.join(auth_config.allowed_domains)}. "
|
||||||
|
f"Your account is from: {user_domain or 'personal Gmail'}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
expires_at = datetime.now() + timedelta(hours=auth_config.session_timeout_hours)
|
||||||
|
request.session.update(
|
||||||
|
{
|
||||||
|
"user_email": user_info["email"],
|
||||||
|
"user_name": user_info.get("name"),
|
||||||
|
"user_picture": user_info.get("picture"),
|
||||||
|
"domain": user_domain,
|
||||||
|
"authenticated_at": datetime.now().isoformat(),
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up oauth state
|
||||||
|
request.session.pop("oauth_state", None)
|
||||||
|
next_url = request.session.pop("oauth_next", "/")
|
||||||
|
|
||||||
|
return RedirectResponse(url=next_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Clear session and redirect to login."""
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(request: Request):
|
||||||
|
"""
|
||||||
|
Return current user info.
|
||||||
|
|
||||||
|
API endpoint for checking auth status.
|
||||||
|
"""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if not user:
|
||||||
|
# Try to get from session directly (in case middleware didn't run)
|
||||||
|
user_email = request.session.get("user_email")
|
||||||
|
if not user_email:
|
||||||
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
user = {
|
||||||
|
"email": user_email,
|
||||||
|
"name": request.session.get("user_name"),
|
||||||
|
"picture": request.session.get("user_picture"),
|
||||||
|
"domain": request.session.get("domain"),
|
||||||
|
}
|
||||||
|
return user
|
||||||
51
soleprint/common/auth/session.py
Normal file
51
soleprint/common/auth/session.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Session helpers for authentication.
|
||||||
|
|
||||||
|
Generic session management, provider-agnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get current authenticated user from session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User dict with email, name, domain, etc. or None if not authenticated.
|
||||||
|
"""
|
||||||
|
session = getattr(request, "session", None)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_email = session.get("user_email")
|
||||||
|
if not user_email:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check expiry
|
||||||
|
expires_at = session.get("expires_at")
|
||||||
|
if expires_at:
|
||||||
|
if datetime.fromisoformat(expires_at) < datetime.now():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": user_email,
|
||||||
|
"name": session.get("user_name"),
|
||||||
|
"picture": session.get("user_picture"),
|
||||||
|
"domain": session.get("domain"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(request: Request) -> dict:
|
||||||
|
"""
|
||||||
|
Get current user or raise 401.
|
||||||
|
|
||||||
|
For use as FastAPI dependency.
|
||||||
|
"""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
return user
|
||||||
216
soleprint/run.py
216
soleprint/run.py
@@ -11,6 +11,7 @@ Usage:
|
|||||||
This is for soleprint development only, not for managed rooms (use docker for those).
|
This is for soleprint development only, not for managed rooms (use docker for those).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -34,6 +35,212 @@ DATA_DIR = SPR_ROOT / "data"
|
|||||||
CFG_DIR = SPR_ROOT / "cfg"
|
CFG_DIR = SPR_ROOT / "cfg"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Vein Loading
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Preload pip packages that share names with veins to prevent shadowing
|
||||||
|
# These must be imported BEFORE any vein that might shadow them
|
||||||
|
_preloaded_packages = []
|
||||||
|
for _pkg in ["google.auth", "jira", "slack_sdk"]:
|
||||||
|
try:
|
||||||
|
__import__(_pkg)
|
||||||
|
_preloaded_packages.append(_pkg)
|
||||||
|
except ImportError:
|
||||||
|
pass # Package not installed, vein will fail gracefully
|
||||||
|
|
||||||
|
|
||||||
|
def load_vein(vein_name: str):
|
||||||
|
"""
|
||||||
|
Load a vein's router dynamically.
|
||||||
|
|
||||||
|
Veins use relative imports for their internal modules (..core, ..models)
|
||||||
|
and absolute imports for shared utilities (oauth, base from veins/).
|
||||||
|
|
||||||
|
IMPORTANT: Vein folder names may shadow pip packages (e.g., 'google', 'jira').
|
||||||
|
We ONLY register under prefixed names (vein_google) to avoid shadowing.
|
||||||
|
Relative imports work because we set __package__ correctly on each module.
|
||||||
|
"""
|
||||||
|
vein_path = SPR_ROOT / "artery" / "veins" / vein_name
|
||||||
|
if not vein_path.exists():
|
||||||
|
raise FileNotFoundError(f"Vein not found: {vein_path}")
|
||||||
|
|
||||||
|
routes_file = vein_path / "api" / "routes.py"
|
||||||
|
if not routes_file.exists():
|
||||||
|
raise FileNotFoundError(f"Vein routes not found: {routes_file}")
|
||||||
|
|
||||||
|
# Use prefixed name to avoid shadowing pip packages
|
||||||
|
vein_prefix = f"vein_{vein_name}"
|
||||||
|
|
||||||
|
# Clear any previously loaded vein modules to avoid conflicts
|
||||||
|
for mod_name in list(sys.modules.keys()):
|
||||||
|
if mod_name == vein_prefix or mod_name.startswith(f"{vein_prefix}."):
|
||||||
|
del sys.modules[mod_name]
|
||||||
|
# Clear shared modules that might have stale references
|
||||||
|
for mod_name in ["oauth", "base"]:
|
||||||
|
if mod_name in sys.modules:
|
||||||
|
del sys.modules[mod_name]
|
||||||
|
|
||||||
|
# Add veins directory to path for shared modules (oauth.py, base.py)
|
||||||
|
veins_path = vein_path.parent
|
||||||
|
if str(veins_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(veins_path))
|
||||||
|
|
||||||
|
# Create the vein package module
|
||||||
|
vein_init = vein_path / "__init__.py"
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
vein_prefix,
|
||||||
|
vein_init if vein_init.exists() else None,
|
||||||
|
submodule_search_locations=[str(vein_path)],
|
||||||
|
)
|
||||||
|
vein_pkg = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[vein_prefix] = vein_pkg
|
||||||
|
|
||||||
|
if spec.loader:
|
||||||
|
spec.loader.exec_module(vein_pkg)
|
||||||
|
|
||||||
|
# Load subpackages (core, api, models)
|
||||||
|
for subpkg in ["core", "models", "api"]:
|
||||||
|
subpkg_path = vein_path / subpkg
|
||||||
|
if subpkg_path.exists():
|
||||||
|
_load_vein_subpackage(vein_pkg, vein_prefix, subpkg, subpkg_path)
|
||||||
|
|
||||||
|
# Load individual modules in core/ and models/ that routes.py needs
|
||||||
|
for subpkg in ["core", "models"]:
|
||||||
|
subpkg_path = vein_path / subpkg
|
||||||
|
if subpkg_path.exists():
|
||||||
|
for py_file in subpkg_path.glob("*.py"):
|
||||||
|
if py_file.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
module_name = py_file.stem
|
||||||
|
_load_vein_module(vein_pkg, vein_prefix, subpkg, module_name, py_file)
|
||||||
|
|
||||||
|
# Now load routes.py with all dependencies available
|
||||||
|
routes_mod = _load_vein_module(vein_pkg, vein_prefix, "api", "routes", routes_file)
|
||||||
|
|
||||||
|
return routes_mod.router
|
||||||
|
|
||||||
|
|
||||||
|
def _load_vein_subpackage(vein_pkg, vein_prefix, subpkg, subpkg_path):
|
||||||
|
"""Load a vein subpackage (core, api, models)."""
|
||||||
|
subpkg_init = subpkg_path / "__init__.py"
|
||||||
|
sub_spec = importlib.util.spec_from_file_location(
|
||||||
|
f"{vein_prefix}.{subpkg}",
|
||||||
|
subpkg_init if subpkg_init.exists() else None,
|
||||||
|
submodule_search_locations=[str(subpkg_path)],
|
||||||
|
)
|
||||||
|
sub_mod = importlib.util.module_from_spec(sub_spec)
|
||||||
|
sys.modules[f"{vein_prefix}.{subpkg}"] = sub_mod
|
||||||
|
setattr(vein_pkg, subpkg, sub_mod)
|
||||||
|
|
||||||
|
if sub_spec.loader:
|
||||||
|
sub_spec.loader.exec_module(sub_mod)
|
||||||
|
|
||||||
|
return sub_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _load_vein_module(vein_pkg, vein_prefix, subpkg, module_name, file_path):
|
||||||
|
"""Load a specific module within a vein subpackage."""
|
||||||
|
mod_spec = importlib.util.spec_from_file_location(
|
||||||
|
f"{vein_prefix}.{subpkg}.{module_name}",
|
||||||
|
file_path,
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(mod_spec)
|
||||||
|
|
||||||
|
# Set __package__ so relative imports resolve via vein_prefix
|
||||||
|
mod.__package__ = f"{vein_prefix}.{subpkg}"
|
||||||
|
|
||||||
|
sys.modules[f"{vein_prefix}.{subpkg}.{module_name}"] = mod
|
||||||
|
|
||||||
|
# Also set on parent package
|
||||||
|
parent_pkg = getattr(vein_pkg, subpkg, None)
|
||||||
|
if parent_pkg:
|
||||||
|
setattr(parent_pkg, module_name, mod)
|
||||||
|
|
||||||
|
mod_spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def mount_veins(app):
|
||||||
|
"""Auto-discover and mount all veins from artery/veins/."""
|
||||||
|
veins_dir = SPR_ROOT / "artery" / "veins"
|
||||||
|
if not veins_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
for vein_path in sorted(veins_dir.iterdir()):
|
||||||
|
if not vein_path.is_dir():
|
||||||
|
continue
|
||||||
|
if vein_path.name.startswith(("_", ".")):
|
||||||
|
continue
|
||||||
|
# Skip non-vein directories (no api/routes.py)
|
||||||
|
if not (vein_path / "api" / "routes.py").exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
vein_name = vein_path.name
|
||||||
|
try:
|
||||||
|
router = load_vein(vein_name)
|
||||||
|
app.include_router(router, prefix=f"/artery/{vein_name}", tags=[vein_name])
|
||||||
|
print(f"Vein mounted: /artery/{vein_name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not load vein '{vein_name}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Authentication Setup (optional, based on room config)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def setup_auth(app, config: dict):
|
||||||
|
"""
|
||||||
|
Configure authentication if enabled in room config.
|
||||||
|
|
||||||
|
Auth is optional - rooms without auth config run without authentication.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from common.auth.config import load_auth_config
|
||||||
|
|
||||||
|
auth_config = load_auth_config(config)
|
||||||
|
if not auth_config:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Auth: enabled for domains {auth_config.allowed_domains or ['*']}")
|
||||||
|
|
||||||
|
# Get session secret
|
||||||
|
session_secret = auth_config.session_secret
|
||||||
|
if session_secret.startswith("ENV:"):
|
||||||
|
session_secret = os.getenv(session_secret[4:], "dev-secret-change-in-prod")
|
||||||
|
|
||||||
|
# Add session middleware
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=session_secret,
|
||||||
|
session_cookie="soleprint_session",
|
||||||
|
max_age=auth_config.session_timeout_hours * 3600,
|
||||||
|
same_site="lax",
|
||||||
|
https_only=os.getenv("HTTPS_ONLY", "").lower() == "true",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize auth routes
|
||||||
|
from common.auth.routes import init_auth
|
||||||
|
from common.auth.routes import router as auth_router
|
||||||
|
|
||||||
|
init_auth(auth_config)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
print("Auth: routes mounted at /auth")
|
||||||
|
|
||||||
|
# Add auth middleware
|
||||||
|
from common.auth.middleware import AuthMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(AuthMiddleware, auth_config=auth_config)
|
||||||
|
except ImportError:
|
||||||
|
# common.auth not available (standalone without auth)
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Auth setup error: {e}")
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict:
|
def load_config() -> dict:
|
||||||
"""Load config.json from cfg/ directory."""
|
"""Load config.json from cfg/ directory."""
|
||||||
config_path = CFG_DIR / "config.json"
|
config_path = CFG_DIR / "config.json"
|
||||||
@@ -91,6 +298,15 @@ def scan_directory(base_path: Path, pattern: str = "*") -> list[dict]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Initialize: Load config, setup auth, mount veins
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
_config = load_config()
|
||||||
|
setup_auth(app, _config)
|
||||||
|
mount_veins(app)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Pawprint Wrapper - Sidebar Logic
|
// Soleprint Wrapper - Sidebar Logic
|
||||||
|
|
||||||
class PawprintSidebar {
|
class SoleprintSidebar {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.config = null;
|
this.config = null;
|
||||||
this.currentUser = null;
|
this.currentUser = null;
|
||||||
@@ -28,38 +28,38 @@ class PawprintSidebar {
|
|||||||
|
|
||||||
async loadConfig() {
|
async loadConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/wrapper/config.json');
|
const response = await fetch("/wrapper/config.json");
|
||||||
this.config = await response.json();
|
this.config = await response.json();
|
||||||
console.log('[Pawprint] Config loaded:', this.config.nest_name);
|
console.log("[Soleprint] Config loaded:", this.config.nest_name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Pawprint] Failed to load config:', error);
|
console.error("[Soleprint] Failed to load config:", error);
|
||||||
// Use default config
|
// Use default config
|
||||||
this.config = {
|
this.config = {
|
||||||
nest_name: 'default',
|
nest_name: "default",
|
||||||
wrapper: {
|
wrapper: {
|
||||||
environment: {
|
environment: {
|
||||||
backend_url: 'http://localhost:8000',
|
backend_url: "http://localhost:8000",
|
||||||
frontend_url: 'http://localhost:3000'
|
frontend_url: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
users: [],
|
||||||
},
|
},
|
||||||
users: []
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createSidebar() {
|
createSidebar() {
|
||||||
const sidebar = document.createElement('div');
|
const sidebar = document.createElement("div");
|
||||||
sidebar.id = 'pawprint-sidebar';
|
sidebar.id = "pawprint-sidebar";
|
||||||
sidebar.innerHTML = this.getSidebarHTML();
|
sidebar.innerHTML = this.getSidebarHTML();
|
||||||
document.body.appendChild(sidebar);
|
document.body.appendChild(sidebar);
|
||||||
this.sidebar = sidebar;
|
this.sidebar = sidebar;
|
||||||
}
|
}
|
||||||
|
|
||||||
createToggleButton() {
|
createToggleButton() {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement("button");
|
||||||
button.id = 'sidebar-toggle';
|
button.id = "sidebar-toggle";
|
||||||
button.innerHTML = '<span class="icon">◀</span>';
|
button.innerHTML = '<span class="icon">◀</span>';
|
||||||
button.title = 'Toggle Pawprint Sidebar (Ctrl+Shift+P)';
|
button.title = "Toggle Soleprint Sidebar (Ctrl+Shift+P)";
|
||||||
document.body.appendChild(button);
|
document.body.appendChild(button);
|
||||||
this.toggleBtn = button;
|
this.toggleBtn = button;
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ class PawprintSidebar {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2>🐾 Pawprint</h2>
|
<h2>🐾 Soleprint</h2>
|
||||||
<div class="nest-name">${this.config.nest_name}</div>
|
<div class="nest-name">${this.config.nest_name}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,22 +83,26 @@ class PawprintSidebar {
|
|||||||
<div id="current-user-display" style="display: none;">
|
<div id="current-user-display" style="display: none;">
|
||||||
<div class="current-user">
|
<div class="current-user">
|
||||||
Logged in as: <strong id="current-username"></strong>
|
Logged in as: <strong id="current-username"></strong>
|
||||||
<button class="logout-btn" onclick="pawprintSidebar.logout()">
|
<button class="logout-btn" onclick="soleprintSidebar.logout()">
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-cards">
|
<div class="user-cards">
|
||||||
${users.map(user => `
|
${users
|
||||||
<div class="user-card" data-user-id="${user.id}" onclick="pawprintSidebar.loginAs('${user.id}')">
|
.map(
|
||||||
|
(user) => `
|
||||||
|
<div class="user-card" data-user-id="${user.id}" onclick="soleprintSidebar.loginAs('${user.id}')">
|
||||||
<div class="icon">${user.icon}</div>
|
<div class="icon">${user.icon}</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">${user.label}</span>
|
<span class="label">${user.label}</span>
|
||||||
<span class="role">${user.role}</span>
|
<span class="role">${user.role}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,18 +123,18 @@ class PawprintSidebar {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
Pawprint Dev Tools
|
Soleprint Dev Tools
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Toggle button
|
// Toggle button
|
||||||
this.toggleBtn.addEventListener('click', () => this.toggle());
|
this.toggleBtn.addEventListener("click", () => this.toggle());
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+Shift+P
|
// Keyboard shortcut: Ctrl+Shift+P
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
|
if (e.ctrlKey && e.shiftKey && e.key === "P") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.toggle();
|
this.toggle();
|
||||||
}
|
}
|
||||||
@@ -138,28 +142,29 @@ class PawprintSidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.sidebar.classList.toggle('expanded');
|
this.sidebar.classList.toggle("expanded");
|
||||||
this.saveSidebarState();
|
this.saveSidebarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
saveSidebarState() {
|
saveSidebarState() {
|
||||||
const isExpanded = this.sidebar.classList.contains('expanded');
|
const isExpanded = this.sidebar.classList.contains("expanded");
|
||||||
localStorage.setItem('pawprint_sidebar_expanded', isExpanded);
|
localStorage.setItem("pawprint_sidebar_expanded", isExpanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSidebarState() {
|
loadSidebarState() {
|
||||||
const isExpanded = localStorage.getItem('pawprint_sidebar_expanded') === 'true';
|
const isExpanded =
|
||||||
|
localStorage.getItem("pawprint_sidebar_expanded") === "true";
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
this.sidebar.classList.add('expanded');
|
this.sidebar.classList.add("expanded");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showStatus(message, type = 'info') {
|
showStatus(message, type = "info") {
|
||||||
const container = document.getElementById('status-container');
|
const container = document.getElementById("status-container");
|
||||||
const statusDiv = document.createElement('div');
|
const statusDiv = document.createElement("div");
|
||||||
statusDiv.className = `status-message ${type}`;
|
statusDiv.className = `status-message ${type}`;
|
||||||
statusDiv.textContent = message;
|
statusDiv.textContent = message;
|
||||||
container.innerHTML = '';
|
container.innerHTML = "";
|
||||||
container.appendChild(statusDiv);
|
container.appendChild(statusDiv);
|
||||||
|
|
||||||
// Auto-remove after 5 seconds
|
// Auto-remove after 5 seconds
|
||||||
@@ -169,22 +174,22 @@ class PawprintSidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginAs(userId) {
|
async loginAs(userId) {
|
||||||
const user = this.config.wrapper.users.find(u => u.id === userId);
|
const user = this.config.wrapper.users.find((u) => u.id === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
this.showStatus(`Logging in as ${user.label}... ⏳`, 'info');
|
this.showStatus(`Logging in as ${user.label}... ⏳`, "info");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backendUrl = this.config.wrapper.environment.backend_url;
|
const backendUrl = this.config.wrapper.environment.backend_url;
|
||||||
const response = await fetch(`${backendUrl}/api/token/`, {
|
const response = await fetch(`${backendUrl}/api/token/`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
password: user.password
|
password: user.password,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -194,17 +199,20 @@ class PawprintSidebar {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Store tokens
|
// Store tokens
|
||||||
localStorage.setItem('access_token', data.access);
|
localStorage.setItem("access_token", data.access);
|
||||||
localStorage.setItem('refresh_token', data.refresh);
|
localStorage.setItem("refresh_token", data.refresh);
|
||||||
|
|
||||||
// Store user info
|
// Store user info
|
||||||
localStorage.setItem('user_info', JSON.stringify({
|
localStorage.setItem(
|
||||||
|
"user_info",
|
||||||
|
JSON.stringify({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
label: user.label,
|
label: user.label,
|
||||||
role: data.details?.role || user.role
|
role: data.details?.role || user.role,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
this.showStatus(`✓ Logged in as ${user.label}`, 'success');
|
this.showStatus(`✓ Logged in as ${user.label}`, "success");
|
||||||
this.currentUser = user;
|
this.currentUser = user;
|
||||||
this.updateCurrentUserDisplay();
|
this.updateCurrentUserDisplay();
|
||||||
|
|
||||||
@@ -212,19 +220,18 @@ class PawprintSidebar {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Pawprint] Login error:', error);
|
console.error("[Soleprint] Login error:", error);
|
||||||
this.showStatus(`✗ Login failed: ${error.message}`, 'error');
|
this.showStatus(`✗ Login failed: ${error.message}`, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem("access_token");
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem("refresh_token");
|
||||||
localStorage.removeItem('user_info');
|
localStorage.removeItem("user_info");
|
||||||
|
|
||||||
this.showStatus('✓ Logged out', 'success');
|
this.showStatus("✓ Logged out", "success");
|
||||||
this.currentUser = null;
|
this.currentUser = null;
|
||||||
this.updateCurrentUserDisplay();
|
this.updateCurrentUserDisplay();
|
||||||
|
|
||||||
@@ -235,52 +242,54 @@ class PawprintSidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkCurrentUser() {
|
checkCurrentUser() {
|
||||||
const userInfo = localStorage.getItem('user_info');
|
const userInfo = localStorage.getItem("user_info");
|
||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
try {
|
try {
|
||||||
this.currentUser = JSON.parse(userInfo);
|
this.currentUser = JSON.parse(userInfo);
|
||||||
this.updateCurrentUserDisplay();
|
this.updateCurrentUserDisplay();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Pawprint] Failed to parse user info:', error);
|
console.error("[Soleprint] Failed to parse user info:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCurrentUserDisplay() {
|
updateCurrentUserDisplay() {
|
||||||
const display = document.getElementById('current-user-display');
|
const display = document.getElementById("current-user-display");
|
||||||
const username = document.getElementById('current-username');
|
const username = document.getElementById("current-username");
|
||||||
|
|
||||||
if (this.currentUser) {
|
if (this.currentUser) {
|
||||||
display.style.display = 'block';
|
display.style.display = "block";
|
||||||
username.textContent = this.currentUser.username;
|
username.textContent = this.currentUser.username;
|
||||||
|
|
||||||
// Highlight active user card
|
// Highlight active user card
|
||||||
document.querySelectorAll('.user-card').forEach(card => {
|
document.querySelectorAll(".user-card").forEach((card) => {
|
||||||
card.classList.remove('active');
|
card.classList.remove("active");
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeCard = document.querySelector(`.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`);
|
const activeCard = document.querySelector(
|
||||||
|
`.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`,
|
||||||
|
);
|
||||||
if (activeCard) {
|
if (activeCard) {
|
||||||
activeCard.classList.add('active');
|
activeCard.classList.add("active");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
display.style.display = 'none';
|
display.style.display = "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserIdByUsername(username) {
|
getUserIdByUsername(username) {
|
||||||
const user = this.config.wrapper.users.find(u => u.username === username);
|
const user = this.config.wrapper.users.find((u) => u.username === username);
|
||||||
return user ? user.id : null;
|
return user ? user.id : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize sidebar when DOM is ready
|
// Initialize sidebar when DOM is ready
|
||||||
const pawprintSidebar = new PawprintSidebar();
|
const soleprintSidebar = new SoleprintSidebar();
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', () => pawprintSidebar.init());
|
document.addEventListener("DOMContentLoaded", () => soleprintSidebar.init());
|
||||||
} else {
|
} else {
|
||||||
pawprintSidebar.init();
|
soleprintSidebar.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Pawprint] Sidebar script loaded');
|
console.log("[Soleprint] Sidebar script loaded");
|
||||||
|
|||||||
Reference in New Issue
Block a user