feat: replace local auth with Azure AD SSO (MSAL PKCE)

- New POST /api/auth/microsoft endpoint validates Azure ID token via JWKS
- Removed POST /api/auth/login and /change-password
- Added azure_oid + nullable password_hash to users (migration 0007)
- Auto-provisions all @oliver.agency accounts on first SSO login
- Case-insensitive email matching links existing vadymsamoilenko@ account
- DEV_AUTH_BYPASS flag for local development without MSAL
- Frontend: MSAL loginPopup replaces email/password form
- Added scripts/grant_admin.py for role management

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-07 10:43:19 +01:00
parent dc50dd1d3b
commit 96e6f4ee14
64 changed files with 6296 additions and 175 deletions

View file

@ -10,6 +10,16 @@ BASE_PATH=/cc-dashboard
APP_TITLE=CC Dashboard
LOG_FORMAT=json
# Azure AD SSO (Oliver tenant — shared)
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
ALLOWED_EMAIL_DOMAIN=oliver.agency
# Comma-separated emails that auto-receive admin role on first SSO login
ADMIN_EMAILS=vadymsamoilenko@oliver.agency
# Local dev only — set to true to skip SSO and auto-login as DEV_USER_EMAIL
DEV_AUTH_BYPASS=false
DEV_USER_EMAIL=vadymsamoilenko@oliver.agency
# Azure DevOps
ADO_ORGANIZATION=your-org
ADO_PROJECT=your-project

View file

@ -0,0 +1,29 @@
"""Add azure_oid to users and make password_hash nullable
Revision ID: 0007
Revises: 0006
Create Date: 2026-05-07
"""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("users", sa.Column("azure_oid", sa.String(36), nullable=True))
op.create_unique_constraint("uq_users_azure_oid", "users", ["azure_oid"])
op.create_index("ix_users_azure_oid", "users", ["azure_oid"])
op.alter_column("users", "password_hash", existing_type=sa.String(255), nullable=True)
# Normalize existing emails to lowercase
op.execute("UPDATE users SET email = LOWER(email)")
def downgrade():
op.drop_index("ix_users_azure_oid", table_name="users")
op.drop_constraint("uq_users_azure_oid", "users", type_="unique")
op.drop_column("users", "azure_oid")
op.alter_column("users", "password_hash", existing_type=sa.String(255), nullable=False)

32
scripts/grant_admin.py Normal file
View file

@ -0,0 +1,32 @@
"""Grant admin role to a user by email (SSO users).
Usage:
docker compose exec app python scripts/grant_admin.py user@oliver.agency
"""
import asyncio
import sys
from sqlalchemy import func, select
from src.database import async_session_factory
from src.models import User
async def grant_admin(email: str) -> None:
email = email.strip().lower()
async with async_session_factory() as db:
result = await db.execute(select(User).where(func.lower(User.email) == email))
user = result.scalar_one_or_none()
if user is None:
print(f"User {email!r} not found. They must log in via SSO first.")
sys.exit(1)
user.role = "admin"
await db.commit()
print(f"Granted admin to {user.email} (id={user.id})")
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python scripts/grant_admin.py user@oliver.agency")
sys.exit(1)
asyncio.run(grant_admin(sys.argv[1]))

View file

@ -90,8 +90,19 @@ async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)],
db: AsyncSession = Depends(get_db),
) -> User:
from src.config import settings
from sqlalchemy import select as sa_select, func
if not credentials:
if settings.DEV_AUTH_BYPASS:
result = await db.execute(
sa_select(User).where(func.lower(User.email) == settings.DEV_USER_EMAIL.lower())
)
user = result.scalar_one_or_none()
if user and user.is_active:
return user
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
payload = decode_token(credentials.credentials)
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")

View file

@ -36,6 +36,14 @@ class Settings(BaseSettings):
WEEKLY_REPORT_DAY: int = 6 # 0=Mon ... 6=Sun
WEEKLY_REPORT_HOUR: int = 21
# Azure AD SSO
AZURE_TENANT_ID: str = ""
AZURE_CLIENT_ID: str = ""
ALLOWED_EMAIL_DOMAIN: str = "oliver.agency"
ADMIN_EMAILS: str = "" # comma-separated lowercase
DEV_AUTH_BYPASS: bool = False
DEV_USER_EMAIL: str = "vadymsamoilenko@oliver.agency"
# Logging
LOG_FORMAT: str = "console" # "json" or "console"

View file

@ -33,7 +33,8 @@ class User(Base):
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
azure_oid: Mapped[str | None] = mapped_column(String(36), nullable=True, unique=True, index=True)
role: Mapped[str] = mapped_column(Enum("admin", "user", name="user_role"), default="user", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
daily_overhead_hours: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)

View file

@ -22,9 +22,9 @@ async def create_user(body: UserCreate, admin: AdminUser, db: AsyncSession = Dep
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=body.email,
email=body.email.lower(),
username=body.username,
password_hash=hash_password(body.password),
password_hash=hash_password(body.password) if body.password else None,
role=body.role,
)
db.add(user)

View file

@ -1,27 +1,73 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import (
CurrentUser, create_access_token, create_refresh_token,
decode_token, hash_password, verify_password,
decode_token, hash_password,
)
from src.config import settings
from src.database import get_db
from src.models import User
from src.schemas import (
ChangePasswordRequest, LoginRequest, RefreshRequest,
TokenResponse, UserOut,
)
from src.schemas import MicrosoftLoginRequest, RefreshRequest, TokenResponse, UserOut
from src.sso import validate_microsoft_id_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if not user or not user.is_active or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
def _admin_set() -> set[str]:
return {e.strip().lower() for e in settings.ADMIN_EMAILS.split(",") if e.strip()}
@router.post("/microsoft", response_model=TokenResponse)
async def microsoft_sso(body: MicrosoftLoginRequest, db: AsyncSession = Depends(get_db)):
claims = validate_microsoft_id_token(body.id_token)
raw_email = claims.get("preferred_username") or claims.get("email") or ""
email = raw_email.lower()
oid: str = claims.get("oid", "")
name: str = claims.get("name", "")
if not email or not email.endswith(f"@{settings.ALLOWED_EMAIL_DOMAIN}"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Domain not allowed")
# Find by azure_oid first (most stable), fall back to email match
user: User | None = None
if oid:
result = await db.execute(select(User).where(User.azure_oid == oid))
user = result.scalar_one_or_none()
if user is None:
result = await db.execute(
select(User).where(func.lower(User.email) == email)
)
user = result.scalar_one_or_none()
if user is None:
# First-time SSO login — auto-provision
username = email.split("@")[0]
user = User(
email=email,
username=username,
password_hash=None,
azure_oid=oid or None,
role="admin" if email in _admin_set() else "user",
)
db.add(user)
else:
# Link existing account to Azure OID on first SSO login
if oid and user.azure_oid is None:
user.azure_oid = oid
# Promote to admin if listed
if email in _admin_set() and user.role != "admin":
user.role = "admin"
# Normalize email to lowercase (case-insensitive matching)
if user.email != email:
user.email = email
await db.commit()
await db.refresh(user)
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
@ -42,19 +88,6 @@ async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
)
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Wrong current password")
user.password_hash = hash_password(body.new_password)
await db.commit()
return {"detail": "Password changed"}
@router.get("/me", response_model=UserOut)
async def me(user: CurrentUser):
return user

View file

@ -6,9 +6,8 @@ from pydantic import BaseModel, EmailStr, Field
# ── Auth ──────────────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: EmailStr
password: str
class MicrosoftLoginRequest(BaseModel):
id_token: str
class TokenResponse(BaseModel):
@ -21,11 +20,6 @@ class RefreshRequest(BaseModel):
refresh_token: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
# ── Users ─────────────────────────────────────────────────────────────────────
class UserOut(BaseModel):
@ -43,7 +37,7 @@ class UserOut(BaseModel):
class UserCreate(BaseModel):
email: EmailStr
username: str = Field(min_length=2, max_length=100)
password: str = Field(min_length=8)
password: str | None = Field(default=None, min_length=8)
role: str = Field(default="user", pattern="^(admin|user)$")

55
src/sso.py Normal file
View file

@ -0,0 +1,55 @@
"""Azure AD SSO — validates Microsoft ID tokens via JWKS."""
import time
from typing import Any
import httpx
from fastapi import HTTPException, status
from jose import JWTError, jwt
from src.config import settings
_jwks_cache: dict[str, Any] = {}
_jwks_fetched_at: float = 0.0
_JWKS_TTL = 3600 # seconds
def _get_jwks() -> dict:
global _jwks_cache, _jwks_fetched_at
if time.time() - _jwks_fetched_at < _JWKS_TTL and _jwks_cache:
return _jwks_cache
url = f"https://login.microsoftonline.com/{settings.AZURE_TENANT_ID}/discovery/v2.0/keys"
resp = httpx.get(url, timeout=10)
resp.raise_for_status()
_jwks_cache = resp.json()
_jwks_fetched_at = time.time()
return _jwks_cache
def validate_microsoft_id_token(id_token: str) -> dict:
"""Validate Azure AD ID token and return claims. Raises 401 on any failure."""
if not settings.AZURE_TENANT_ID or not settings.AZURE_CLIENT_ID:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SSO not configured",
)
try:
jwks = _get_jwks()
claims = jwt.decode(
id_token,
jwks,
algorithms=["RS256"],
audience=settings.AZURE_CLIENT_ID,
issuer=f"https://login.microsoftonline.com/{settings.AZURE_TENANT_ID}/v2.0",
options={"verify_at_hash": False},
)
return claims
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid Microsoft token: {exc}",
)
except httpx.HTTPError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Failed to fetch Azure AD keys: {exc}",
)

View file

@ -1 +0,0 @@
import{d as p,u as y,x as h,c as r,a as t,e as n,n as v,w as d,f as b,r as u,o as s,F as g,l as k,t as a,k as m,i as A}from"./index-yrXqsixb.js";import{a as w}from"./admin-BRKJZipt.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-BZS0eQer.js";import{_ as x}from"./Badge.vue_vue_type_script_setup_true_lang-18ft6dLh.js";import{_ as V,a as $}from"./utils-D_0J15Md.js";const N={class:"p-6"},C={key:0,class:"flex items-center justify-center h-20"},D={class:"w-full"},E={class:"px-4 py-3"},F={class:"text-sm font-medium text-foreground"},R={class:"px-4 py-3 text-sm text-muted-foreground"},U={class:"px-4 py-3"},j={class:"px-4 py-3"},I={class:"px-4 py-3 text-xs text-muted-foreground"},G=p({__name:"AdminView",setup(J){const f=y(),_=b(),i=u([]),l=u(!1);return h(async()=>{if(!f.isAdmin){_.push("/");return}l.value=!0;try{const c=await w.users();i.value=c.data}finally{l.value=!1}}),(c,o)=>(s(),r("div",N,[o[1]||(o[1]=t("h2",{class:"text-lg font-semibold text-foreground mb-6"},"Admin — Users",-1)),l.value?(s(),r("div",C,[n(V,{class:"text-primary"})])):(s(),v(B,{key:1},{default:d(()=>[n(S,{class:"p-0"},{default:d(()=>[t("table",D,[o[0]||(o[0]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"User"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Email"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Role"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Status"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Joined")])],-1)),t("tbody",null,[(s(!0),r(g,null,k(i.value,e=>(s(),r("tr",{key:e.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",E,[t("p",F,a(e.username),1)]),t("td",R,a(e.email),1),t("td",U,[n(x,{variant:e.role==="admin"?"default":"secondary",class:"text-xs"},{default:d(()=>[m(a(e.role),1)]),_:2},1032,["variant"])]),t("td",j,[n(x,{variant:e.is_active?"success":"outline",class:"text-xs"},{default:d(()=>[m(a(e.is_active?"Active":"Inactive"),1)]),_:2},1032,["variant"])]),t("td",I,a(A($)(e.created_at)),1)]))),128))])])]),_:1})]),_:1}))]))}});export{G as default};

View file

@ -0,0 +1 @@
import{d as _,u as y,v as h,c as r,a as t,e as n,k as v,w as d,f as b,q as m,o as s,F as g,r as k,t as a,p as u,h as A}from"./index-DzSm5_bv.js";import{a as w}from"./admin-DOjSzxjn.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-B899D1fp.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-CaB6FyQ0.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-DxuuceC3.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6"},C={key:0,class:"flex items-center justify-center h-20"},D={class:"w-full"},E={class:"px-4 py-3"},F={class:"text-sm font-medium text-foreground"},R={class:"px-4 py-3 text-sm text-muted-foreground"},U={class:"px-4 py-3"},j={class:"px-4 py-3"},q={class:"px-4 py-3 text-xs text-muted-foreground"},H=_({__name:"AdminView",setup(I){const x=y(),p=b(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){p.push("/");return}l.value=!0;try{const c=await w.users();i.value=c.data}finally{l.value=!1}}),(c,o)=>(s(),r("div",N,[o[1]||(o[1]=t("h2",{class:"text-lg font-semibold text-foreground mb-6"},"Admin — Users",-1)),l.value?(s(),r("div",C,[n(V,{class:"text-primary"})])):(s(),v(B,{key:1},{default:d(()=>[n(S,{class:"p-0"},{default:d(()=>[t("table",D,[o[0]||(o[0]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"User"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Email"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Role"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Status"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Joined")])],-1)),t("tbody",null,[(s(!0),r(g,null,k(i.value,e=>(s(),r("tr",{key:e.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",E,[t("p",F,a(e.username),1)]),t("td",R,a(e.email),1),t("td",U,[n(f,{variant:e.role==="admin"?"default":"secondary",class:"text-xs"},{default:d(()=>[u(a(e.role),1)]),_:2},1032,["variant"])]),t("td",j,[n(f,{variant:e.is_active?"success":"outline",class:"text-xs"},{default:d(()=>[u(a(e.is_active?"Active":"Inactive"),1)]),_:2},1032,["variant"])]),t("td",q,a(A($)(e.created_at)),1)]))),128))])])]),_:1})]),_:1}))]))}});export{H as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{c as a}from"./utils-D_0J15Md.js";import{d as n,o as s,c as o,p as d,i,s as c}from"./index-yrXqsixb.js";const f=n({__name:"Badge",props:{variant:{default:"default"},class:{}},setup(r){const e=r;return(t,l)=>(s(),o("span",{class:d(i(a)("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",{"bg-primary text-primary-foreground":e.variant==="default","bg-secondary text-secondary-foreground":e.variant==="secondary","bg-destructive text-destructive-foreground":e.variant==="destructive","border border-border text-foreground":e.variant==="outline","bg-emerald-500/20 text-emerald-400":e.variant==="success","bg-amber-500/20 text-amber-400":e.variant==="warning"},e.class))},[c(t.$slots,"default")],2))}});export{f as _};
import{c as a}from"./utils-7WVCegLb.js";import{d as n,o,c as s,n as d,h as i,m as c}from"./index-DzSm5_bv.js";const f=n({__name:"Badge",props:{variant:{default:"default"},class:{}},setup(r){const e=r;return(t,l)=>(o(),s("span",{class:d(i(a)("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",{"bg-primary text-primary-foreground":e.variant==="default","bg-secondary text-secondary-foreground":e.variant==="secondary","bg-destructive text-destructive-foreground":e.variant==="destructive","border border-border text-foreground":e.variant==="outline","bg-emerald-500/20 text-emerald-400":e.variant==="success","bg-amber-500/20 text-amber-400":e.variant==="warning"},e.class))},[c(t.$slots,"default")],2))}});export{f as _};

View file

@ -0,0 +1 @@
import{_ as c}from"./Spinner.vue_vue_type_script_setup_true_lang-DxuuceC3.js";import{c as l}from"./utils-7WVCegLb.js";import{d as u,c as m,n as f,k as b,i as v,m as g,j as p,o as n}from"./index-DzSm5_bv.js";const y=["type","disabled"],z=u({__name:"Button",props:{variant:{default:"default"},size:{default:"md"},loading:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},type:{default:"button"},class:{}},emits:["click"],setup(t,{emit:s}){const e=t,a=s,r=p(()=>l("inline-flex items-center justify-center rounded-md font-medium transition-colors","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:pointer-events-none disabled:opacity-50",{"bg-primary text-primary-foreground hover:bg-primary/90":e.variant==="default","border border-input bg-background hover:bg-accent hover:text-accent-foreground":e.variant==="outline","hover:bg-accent hover:text-accent-foreground":e.variant==="ghost","bg-destructive text-destructive-foreground hover:bg-destructive/90":e.variant==="destructive","bg-secondary text-secondary-foreground hover:bg-secondary/80":e.variant==="secondary","underline-offset-4 hover:underline text-primary":e.variant==="link","h-8 px-3 text-xs":e.size==="sm","h-10 px-4 py-2 text-sm":e.size==="md","h-11 px-8 text-base":e.size==="lg","h-9 w-9 p-0":e.size==="icon"},e.class));return(i,o)=>(n(),m("button",{class:f(r.value),type:t.type,disabled:t.disabled||t.loading,onClick:o[0]||(o[0]=d=>a("click",d))},[t.loading?(n(),b(c,{key:0,size:"sm",class:"mr-2"})):v("",!0),g(i.$slots,"default")],10,y))}});export{z as _};

View file

@ -1 +0,0 @@
import{c,_ as l}from"./utils-D_0J15Md.js";import{d as u,c as f,p as m,n as b,j as v,s as g,m as p,o as n}from"./index-yrXqsixb.js";const y=["type","disabled"],k=u({__name:"Button",props:{variant:{default:"default"},size:{default:"md"},loading:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},type:{default:"button"},class:{}},emits:["click"],setup(t,{emit:o}){const e=t,a=o,r=p(()=>c("inline-flex items-center justify-center rounded-md font-medium transition-colors","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:pointer-events-none disabled:opacity-50",{"bg-primary text-primary-foreground hover:bg-primary/90":e.variant==="default","border border-input bg-background hover:bg-accent hover:text-accent-foreground":e.variant==="outline","hover:bg-accent hover:text-accent-foreground":e.variant==="ghost","bg-destructive text-destructive-foreground hover:bg-destructive/90":e.variant==="destructive","bg-secondary text-secondary-foreground hover:bg-secondary/80":e.variant==="secondary","underline-offset-4 hover:underline text-primary":e.variant==="link","h-8 px-3 text-xs":e.size==="sm","h-10 px-4 py-2 text-sm":e.size==="md","h-11 px-8 text-base":e.size==="lg","h-9 w-9 p-0":e.size==="icon"},e.class));return(i,s)=>(n(),f("button",{class:m(r.value),type:t.type,disabled:t.disabled||t.loading,onClick:s[0]||(s[0]=d=>a("click",d))},[t.loading?(n(),b(l,{key:0,size:"sm",class:"mr-2"})):v("",!0),g(i.$slots,"default")],10,y))}});export{k as _};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{c as e}from"./utils-7WVCegLb.js";import{d as o,c as n,n as t,h as c,m as l,o as p}from"./index-DzSm5_bv.js";const _=o({__name:"Card",props:{class:{}},setup(s){const a=s;return(r,d)=>(p(),n("div",{class:t(c(e)("rounded-lg border bg-card text-card-foreground shadow-sm",a.class))},[l(r.$slots,"default")],2))}}),f=o({__name:"CardContent",props:{class:{}},setup(s){const a=s;return(r,d)=>(p(),n("div",{class:t(c(e)("p-6 pt-0",a.class))},[l(r.$slots,"default")],2))}});export{_,f as a};

View file

@ -1 +0,0 @@
import{c as e}from"./utils-D_0J15Md.js";import{d as o,c as t,p as n,i as c,s as p,o as l}from"./index-yrXqsixb.js";const _=o({__name:"Card",props:{class:{}},setup(s){const a=s;return(r,d)=>(l(),t("div",{class:n(c(e)("rounded-lg border bg-card text-card-foreground shadow-sm",a.class))},[p(r.$slots,"default")],2))}}),f=o({__name:"CardContent",props:{class:{}},setup(s){const a=s;return(r,d)=>(l(),t("div",{class:n(c(e)("p-6 pt-0",a.class))},[p(r.$slots,"default")],2))}});export{_,f as a};

View file

@ -1 +0,0 @@
import{c as t}from"./utils-D_0J15Md.js";import{d as o,o as n,c as r,p as c,i as l,s as p}from"./index-yrXqsixb.js";const f=o({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(a,i)=>(n(),r("div",{class:c(l(t)("flex flex-col space-y-1.5 p-6",e.class))},[p(a.$slots,"default")],2))}}),_=o({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(a,i)=>(n(),r("h3",{class:c(l(t)("text-lg font-semibold leading-none tracking-tight",e.class))},[p(a.$slots,"default")],2))}});export{f as _,_ as a};

View file

@ -0,0 +1 @@
import{c as t}from"./utils-7WVCegLb.js";import{d as n,o,c as r,n as c,h as l,m as p}from"./index-DzSm5_bv.js";const f=n({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(a,m)=>(o(),r("div",{class:c(l(t)("flex flex-col space-y-1.5 p-6",e.class))},[p(a.$slots,"default")],2))}}),_=n({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(a,m)=>(o(),r("h3",{class:c(l(t)("text-lg font-semibold leading-none tracking-tight",e.class))},[p(a.$slots,"default")],2))}});export{f as _,_ as a};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{d as S,x as $,c as a,a as s,i as r,n as x,w as l,j as u,e as d,o as n,k as m,t as i,F as _,l as p,p as y,f as I,r as z,m as A,K as v}from"./index-yrXqsixb.js";import{u as j}from"./devops-C_7zqRan.js";import{_ as k,a as h}from"./CardContent.vue_vue_type_script_setup_true_lang-BZS0eQer.js";import{a as D,_ as V}from"./CardTitle.vue_vue_type_script_setup_true_lang-Bs99oJeq.js";import{_ as B}from"./Button.vue_vue_type_script_setup_true_lang-XMbqbqq8.js";import{_ as b}from"./utils-D_0J15Md.js";const L={class:"p-6 space-y-6"},W={class:"flex items-center justify-between gap-4 flex-wrap"},F={class:"flex items-center gap-2"},R={key:0,class:"flex items-center gap-2 text-sm text-muted-foreground"},E={key:1,class:"flex items-center gap-3"},K={class:"text-sm text-foreground"},O={key:0,class:"text-xs text-muted-foreground ml-2"},G={key:2,class:"flex items-center gap-3"},M={key:3,class:"text-xs text-destructive mt-2"},T={class:"flex items-center justify-between gap-3 flex-wrap"},q={class:"flex items-center rounded-lg border border-border overflow-hidden bg-muted/30"},H=["onClick"],J={key:0,class:"flex items-center justify-center py-8"},P={key:1,class:"text-center py-8 text-sm text-muted-foreground"},Q={key:2,class:"space-y-1"},U={class:"text-xs font-mono text-muted-foreground w-10 shrink-0"},X={class:"flex-1 min-w-0"},Y={class:"text-sm text-foreground truncate"},Z={class:"text-xs text-muted-foreground"},tt=["href"],lt=S({__name:"DevopsView",setup(et){const w=I(),t=j(),c=z("All");$(async()=>{await t.fetchIntegration(),t.integration&&await t.fetchWorkItems()});const f=A(()=>c.value==="All"?t.workItems:t.workItems.filter(g=>g.state===c.value));async function C(){try{await t.sync(),v.success("Sync complete"),await t.fetchWorkItems()}catch{v.error(t.error??"Sync failed")}}return(g,e)=>(n(),a("div",L,[s("div",W,[e[2]||(e[2]=s("h2",{class:"text-lg font-semibold text-foreground"},"Azure DevOps",-1)),s("div",F,[r(t).integration?(n(),x(B,{key:0,variant:"outline",size:"sm",loading:r(t).syncing,onClick:C},{default:l(()=>[...e[1]||(e[1]=[m(" Sync Now ",-1)])]),_:1},8,["loading"])):u("",!0)])]),d(k,null,{default:l(()=>[d(h,{class:"pt-4"},{default:l(()=>{var o;return[r(t).loading&&!r(t).integration?(n(),a("div",R,[d(b,{size:"sm"}),e[3]||(e[3]=s("span",null,"Loading...",-1))])):r(t).integration?(n(),a("div",E,[e[6]||(e[6]=s("div",{class:"h-2 w-2 rounded-full bg-[hsl(var(--success))]"},null,-1)),s("span",K,[e[4]||(e[4]=m(" Connected to ",-1)),s("strong",null,i(r(t).integration.organization),1),e[5]||(e[5]=m(" / ",-1)),s("strong",null,i(r(t).integration.project),1)]),r(t).integration.last_synced_at?(n(),a("span",O," Last synced: "+i(new Date(r(t).integration.last_synced_at).toLocaleString()),1)):u("",!0)])):(n(),a("div",G,[e[7]||(e[7]=s("div",{class:"h-2 w-2 rounded-full bg-muted-foreground"},null,-1)),e[8]||(e[8]=s("span",{class:"text-sm text-muted-foreground"},"Not connected.",-1)),s("button",{class:"text-sm text-primary hover:underline",onClick:e[0]||(e[0]=N=>r(w).push("/settings"))}," Go to Settings to connect ")])),(o=r(t).integration)!=null&&o.last_sync_error?(n(),a("p",M," Error: "+i(r(t).integration.last_sync_error),1)):u("",!0)]}),_:1})]),_:1}),r(t).integration?(n(),x(k,{key:0},{default:l(()=>[d(V,{class:"pb-2"},{default:l(()=>[s("div",T,[d(D,{class:"text-sm"},{default:l(()=>[...e[9]||(e[9]=[m("Work Items",-1)])]),_:1}),s("div",q,[(n(),a(_,null,p(["All","Active","Resolved","Closed"],o=>s("button",{key:o,class:y(["px-3 py-1 text-xs font-medium transition-colors",c.value===o?"bg-primary text-primary-foreground":"text-muted-foreground hover:text-foreground hover:bg-muted/50"]),onClick:N=>c.value=o},i(o),11,H)),64))])])]),_:1}),d(h,null,{default:l(()=>[r(t).loading?(n(),a("div",J,[d(b,{size:"md",class:"text-primary"})])):f.value.length===0?(n(),a("div",P," No work items found ")):(n(),a("div",Q,[(n(!0),a(_,null,p(f.value,o=>(n(),a("div",{key:o.id,class:"flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/30 transition-colors"},[s("span",U,"#"+i(o.ado_id),1),s("div",X,[s("p",Y,i(o.title),1),s("p",Z,i(o.type),1)]),s("span",{class:y(["text-xs px-2 py-0.5 rounded-full shrink-0",o.state==="Active"?"bg-blue-500/10 text-blue-400":o.state==="Resolved"?"bg-green-500/10 text-green-400":(o.state==="Closed","bg-muted text-muted-foreground")])},i(o.state),3),o.url?(n(),a("a",{key:0,href:o.url,target:"_blank",class:"text-xs text-primary hover:underline shrink-0"}," Open → ",8,tt)):u("",!0)]))),128))]))]),_:1})]),_:1})):u("",!0)]))}});export{lt as default};

View file

@ -0,0 +1 @@
import{d as S,v as $,c as a,a as s,h as r,k as x,w as l,i as u,e as d,o as n,p as m,t as i,F as p,r as _,n as y,f as I,q as z,j as A,K as v}from"./index-DzSm5_bv.js";import{u as j}from"./devops-S5lsRUq3.js";import{_ as k,a as h}from"./CardContent.vue_vue_type_script_setup_true_lang-B899D1fp.js";import{a as D,_ as V}from"./CardTitle.vue_vue_type_script_setup_true_lang-ByUGRP-t.js";import{_ as B}from"./Button.vue_vue_type_script_setup_true_lang-D97aKlXO.js";import{_ as b}from"./Spinner.vue_vue_type_script_setup_true_lang-DxuuceC3.js";import"./utils-7WVCegLb.js";const L={class:"p-6 space-y-6"},W={class:"flex items-center justify-between gap-4 flex-wrap"},F={class:"flex items-center gap-2"},R={key:0,class:"flex items-center gap-2 text-sm text-muted-foreground"},E={key:1,class:"flex items-center gap-3"},K={class:"text-sm text-foreground"},O={key:0,class:"text-xs text-muted-foreground ml-2"},q={key:2,class:"flex items-center gap-3"},G={key:3,class:"text-xs text-destructive mt-2"},M={class:"flex items-center justify-between gap-3 flex-wrap"},T={class:"flex items-center rounded-lg border border-border overflow-hidden bg-muted/30"},H=["onClick"],J={key:0,class:"flex items-center justify-center py-8"},P={key:1,class:"text-center py-8 text-sm text-muted-foreground"},Q={key:2,class:"space-y-1"},U={class:"text-xs font-mono text-muted-foreground w-10 shrink-0"},X={class:"flex-1 min-w-0"},Y={class:"text-sm text-foreground truncate"},Z={class:"text-xs text-muted-foreground"},tt=["href"],dt=S({__name:"DevopsView",setup(et){const w=I(),t=j(),c=z("All");$(async()=>{await t.fetchIntegration(),t.integration&&await t.fetchWorkItems()});const f=A(()=>c.value==="All"?t.workItems:t.workItems.filter(g=>g.state===c.value));async function C(){try{await t.sync(),v.success("Sync complete"),await t.fetchWorkItems()}catch{v.error(t.error??"Sync failed")}}return(g,e)=>(n(),a("div",L,[s("div",W,[e[2]||(e[2]=s("h2",{class:"text-lg font-semibold text-foreground"},"Azure DevOps",-1)),s("div",F,[r(t).integration?(n(),x(B,{key:0,variant:"outline",size:"sm",loading:r(t).syncing,onClick:C},{default:l(()=>[...e[1]||(e[1]=[m(" Sync Now ",-1)])]),_:1},8,["loading"])):u("",!0)])]),d(k,null,{default:l(()=>[d(h,{class:"pt-4"},{default:l(()=>{var o;return[r(t).loading&&!r(t).integration?(n(),a("div",R,[d(b,{size:"sm"}),e[3]||(e[3]=s("span",null,"Loading...",-1))])):r(t).integration?(n(),a("div",E,[e[6]||(e[6]=s("div",{class:"h-2 w-2 rounded-full bg-[hsl(var(--success))]"},null,-1)),s("span",K,[e[4]||(e[4]=m(" Connected to ",-1)),s("strong",null,i(r(t).integration.organization),1),e[5]||(e[5]=m(" / ",-1)),s("strong",null,i(r(t).integration.project),1)]),r(t).integration.last_synced_at?(n(),a("span",O," Last synced: "+i(new Date(r(t).integration.last_synced_at).toLocaleString()),1)):u("",!0)])):(n(),a("div",q,[e[7]||(e[7]=s("div",{class:"h-2 w-2 rounded-full bg-muted-foreground"},null,-1)),e[8]||(e[8]=s("span",{class:"text-sm text-muted-foreground"},"Not connected.",-1)),s("button",{class:"text-sm text-primary hover:underline",onClick:e[0]||(e[0]=N=>r(w).push("/settings"))}," Go to Settings to connect ")])),(o=r(t).integration)!=null&&o.last_sync_error?(n(),a("p",G," Error: "+i(r(t).integration.last_sync_error),1)):u("",!0)]}),_:1})]),_:1}),r(t).integration?(n(),x(k,{key:0},{default:l(()=>[d(V,{class:"pb-2"},{default:l(()=>[s("div",M,[d(D,{class:"text-sm"},{default:l(()=>[...e[9]||(e[9]=[m("Work Items",-1)])]),_:1}),s("div",T,[(n(),a(p,null,_(["All","Active","Resolved","Closed"],o=>s("button",{key:o,class:y(["px-3 py-1 text-xs font-medium transition-colors",c.value===o?"bg-primary text-primary-foreground":"text-muted-foreground hover:text-foreground hover:bg-muted/50"]),onClick:N=>c.value=o},i(o),11,H)),64))])])]),_:1}),d(h,null,{default:l(()=>[r(t).loading?(n(),a("div",J,[d(b,{size:"md",class:"text-primary"})])):f.value.length===0?(n(),a("div",P," No work items found ")):(n(),a("div",Q,[(n(!0),a(p,null,_(f.value,o=>(n(),a("div",{key:o.id,class:"flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/30 transition-colors"},[s("span",U,"#"+i(o.ado_id),1),s("div",X,[s("p",Y,i(o.title),1),s("p",Z,i(o.type),1)]),s("span",{class:y(["text-xs px-2 py-0.5 rounded-full shrink-0",o.state==="Active"?"bg-blue-500/10 text-blue-400":o.state==="Resolved"?"bg-green-500/10 text-green-400":(o.state==="Closed","bg-muted text-muted-foreground")])},i(o.state),3),o.url?(n(),a("a",{key:0,href:o.url,target:"_blank",class:"text-xs text-primary hover:underline shrink-0"}," Open → ",8,tt)):u("",!0)]))),128))]))]),_:1})]),_:1})):u("",!0)]))}});export{dt as default};

View file

@ -1 +0,0 @@
import{d as y,x as k,E as b,n as h,G as x,e as c,T as g,w as u,o as a,c as n,a as o,s as r,t as m,j as i,p as w}from"./index-yrXqsixb.js";import{_ as $}from"./Button.vue_vue_type_script_setup_true_lang-XMbqbqq8.js";const C={key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4"},B=["aria-label"],j={key:0,class:"flex items-center justify-between p-6 pb-4"},E={class:"text-lg font-semibold text-foreground"},z={key:0,class:"text-sm text-muted-foreground mt-1"},L={class:"px-6 pb-4"},M={key:1,class:"flex justify-end gap-2 px-6 pb-6"},V=y({__name:"Dialog",props:{open:{type:Boolean},title:{},description:{},maxWidth:{default:"max-w-lg"}},emits:["close"],setup(e,{emit:f}){const p=e,l=f;function d(t){t.key==="Escape"&&p.open&&l("close")}return k(()=>document.addEventListener("keydown",d)),b(()=>document.removeEventListener("keydown",d)),(t,s)=>(a(),h(x,{to:"body"},[c(g,{"enter-active-class":"transition-opacity duration-200","enter-from-class":"opacity-0","enter-to-class":"opacity-100","leave-active-class":"transition-opacity duration-200","leave-from-class":"opacity-100","leave-to-class":"opacity-0"},{default:u(()=>[e.open?(a(),n("div",C,[o("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:s[0]||(s[0]=v=>l("close"))}),o("div",{class:w(["relative w-full bg-card border border-border rounded-lg shadow-xl z-10",e.maxWidth]),role:"dialog","aria-modal":!0,"aria-label":e.title},[e.title||t.$slots.header?(a(),n("div",j,[o("div",null,[r(t.$slots,"header",{},()=>[o("h2",E,m(e.title),1),e.description?(a(),n("p",z,m(e.description),1)):i("",!0)])]),c($,{variant:"ghost",size:"icon",class:"shrink-0",onClick:s[1]||(s[1]=v=>l("close"))},{default:u(()=>[...s[2]||(s[2]=[o("svg",{class:"h-4 w-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])]),_:1})])):i("",!0),o("div",L,[r(t.$slots,"default")]),t.$slots.footer?(a(),n("div",M,[r(t.$slots,"footer")])):i("",!0)],10,B)])):i("",!0)]),_:3})]))}});export{V as _};

View file

@ -0,0 +1 @@
import{d as k,v as y,E as b,k as h,G as g,e as c,T as x,w as m,o as a,c as n,a as o,m as r,t as u,i,n as w}from"./index-DzSm5_bv.js";import{_ as $}from"./Button.vue_vue_type_script_setup_true_lang-D97aKlXO.js";const C={key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4"},B=["aria-label"],E={key:0,class:"flex items-center justify-between p-6 pb-4"},j={class:"text-lg font-semibold text-foreground"},z={key:0,class:"text-sm text-muted-foreground mt-1"},L={class:"px-6 pb-4"},M={key:1,class:"flex justify-end gap-2 px-6 pb-6"},V=k({__name:"Dialog",props:{open:{type:Boolean},title:{},description:{},maxWidth:{default:"max-w-lg"}},emits:["close"],setup(e,{emit:f}){const p=e,l=f;function d(t){t.key==="Escape"&&p.open&&l("close")}return y(()=>document.addEventListener("keydown",d)),b(()=>document.removeEventListener("keydown",d)),(t,s)=>(a(),h(g,{to:"body"},[c(x,{"enter-active-class":"transition-opacity duration-200","enter-from-class":"opacity-0","enter-to-class":"opacity-100","leave-active-class":"transition-opacity duration-200","leave-from-class":"opacity-100","leave-to-class":"opacity-0"},{default:m(()=>[e.open?(a(),n("div",C,[o("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:s[0]||(s[0]=v=>l("close"))}),o("div",{class:w(["relative w-full bg-card border border-border rounded-lg shadow-xl z-10",e.maxWidth]),role:"dialog","aria-modal":!0,"aria-label":e.title},[e.title||t.$slots.header?(a(),n("div",E,[o("div",null,[r(t.$slots,"header",{},()=>[o("h2",j,u(e.title),1),e.description?(a(),n("p",z,u(e.description),1)):i("",!0)])]),c($,{variant:"ghost",size:"icon",class:"shrink-0",onClick:s[1]||(s[1]=v=>l("close"))},{default:m(()=>[...s[2]||(s[2]=[o("svg",{class:"h-4 w-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])]),_:1})])):i("",!0),o("div",L,[r(t.$slots,"default")]),t.$slots.footer?(a(),n("div",M,[r(t.$slots,"footer")])):i("",!0)],10,B)])):i("",!0)]),_:3})]))}});export{V as _};

View file

@ -1 +1 @@
import{c as i}from"./utils-D_0J15Md.js";import{d,c as s,p as u,i as m,o as r}from"./index-yrXqsixb.js";const c=["id","name","type","value","placeholder","disabled","autocomplete","min","max","step"],g=d({__name:"Input",props:{modelValue:{},type:{},placeholder:{},disabled:{type:Boolean},class:{},id:{},name:{},autocomplete:{},min:{},max:{},step:{}},emits:["update:modelValue","change","blur","focus"],setup(e,{emit:a}){const n=e,o=a;return(f,t)=>(r(),s("input",{id:e.id,name:e.name,type:e.type??"text",value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,autocomplete:e.autocomplete,min:e.min,max:e.max,step:e.step,class:u(m(i)("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm","ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium","placeholder:text-muted-foreground","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-50",n.class)),onInput:t[0]||(t[0]=l=>o("update:modelValue",l.target.value)),onChange:t[1]||(t[1]=l=>o("change",l.target.value)),onBlur:t[2]||(t[2]=l=>o("blur",l)),onFocus:t[3]||(t[3]=l=>o("focus",l))},null,42,c))}});export{g as _};
import{c as i}from"./utils-7WVCegLb.js";import{d,c as s,n as u,h as m,o as r}from"./index-DzSm5_bv.js";const c=["id","name","type","value","placeholder","disabled","autocomplete","min","max","step"],g=d({__name:"Input",props:{modelValue:{},type:{},placeholder:{},disabled:{type:Boolean},class:{},id:{},name:{},autocomplete:{},min:{},max:{},step:{}},emits:["update:modelValue","change","blur","focus"],setup(e,{emit:n}){const a=e,o=n;return(f,t)=>(r(),s("input",{id:e.id,name:e.name,type:e.type??"text",value:e.modelValue,placeholder:e.placeholder,disabled:e.disabled,autocomplete:e.autocomplete,min:e.min,max:e.max,step:e.step,class:u(m(i)("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm","ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium","placeholder:text-muted-foreground","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-50",a.class)),onInput:t[0]||(t[0]=l=>o("update:modelValue",l.target.value)),onChange:t[1]||(t[1]=l=>o("change",l.target.value)),onBlur:t[2]||(t[2]=l=>o("blur",l)),onFocus:t[3]||(t[3]=l=>o("focus",l))},null,42,c))}});export{g as _};

View file

@ -0,0 +1 @@
import{a as b}from"./admin-DOjSzxjn.js";import{_ as K,a as $}from"./CardContent.vue_vue_type_script_setup_true_lang-B899D1fp.js";import{_ as v}from"./Button.vue_vue_type_script_setup_true_lang-D97aKlXO.js";import{_ as V}from"./Dialog.vue_vue_type_script_setup_true_lang-Bpehdtti.js";import{_ as N}from"./Input.vue_vue_type_script_setup_true_lang-DX_izdWK.js";import{_ as A}from"./Spinner.vue_vue_type_script_setup_true_lang-DxuuceC3.js";import{d as B,v as L,c as l,a as t,e as r,w as n,q as i,o as a,p,F as P,r as F,t as u,h as k,k as I,i as j,K as y}from"./index-DzSm5_bv.js";import{a as h}from"./utils-7WVCegLb.js";const D={class:"p-6"},R={class:"flex items-center justify-between mb-6"},z={key:0,class:"flex items-center justify-center h-20"},M={key:1,class:"text-center text-muted-foreground py-8 text-sm"},T={key:2,class:"w-full"},U={class:"px-4 py-3 text-sm text-foreground"},q={class:"px-4 py-3 text-sm font-mono text-muted-foreground"},E={class:"px-4 py-3 text-xs text-muted-foreground"},H={class:"px-4 py-3 text-xs text-muted-foreground"},S={class:"px-4 py-3 text-right"},G={class:"space-y-4"},J={key:0,class:"rounded-md bg-emerald-500/10 border border-emerald-500/30 p-3"},O={class:"text-xs font-mono text-foreground break-all"},Q={key:1,class:"space-y-1.5"},re=B({__name:"KeysView",setup(W){const f=i([]),_=i(!1),c=i(!1),m=i(""),x=i(!1),d=i(null);L(()=>g());async function g(){_.value=!0;try{const o=await b.keys();f.value=o.data}finally{_.value=!1}}async function w(){if(m.value.trim()){x.value=!0;try{const o=await b.createKey({label:m.value});d.value=o.data.key,y.success("API key created"),await g(),m.value=""}catch{y.error("Failed to create key")}finally{x.value=!1}}}async function C(o){if(confirm(`Revoke key "${o.label}"? This cannot be undone.`))try{await b.revokeKey(o.id),y.success("Key revoked"),f.value=f.value.filter(e=>e.id!==o.id)}catch{y.error("Failed to revoke key")}}return(o,e)=>(a(),l("div",D,[t("div",R,[e[5]||(e[5]=t("h2",{class:"text-lg font-semibold text-foreground"},"API Keys",-1)),r(v,{size:"sm",onClick:e[0]||(e[0]=s=>{c.value=!0,d.value=null})},{default:n(()=>[...e[4]||(e[4]=[t("svg",{class:"h-4 w-4 mr-1.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),p(" New Key ",-1)])]),_:1})]),r(K,null,{default:n(()=>[r($,{class:"p-0"},{default:n(()=>[_.value?(a(),l("div",z,[r(A,{class:"text-primary"})])):f.value.length===0?(a(),l("div",M," No API keys ")):(a(),l("table",T,[e[7]||(e[7]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Label"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Prefix"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Created"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Last Used"),t("th",{class:"px-4 py-3"})])],-1)),t("tbody",null,[(a(!0),l(P,null,F(f.value,s=>(a(),l("tr",{key:s.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",U,u(s.label),1),t("td",q,u(s.prefix)+"...",1),t("td",E,u(k(h)(s.created_at)),1),t("td",H,u(s.last_used?k(h)(s.last_used):"Never"),1),t("td",S,[r(v,{variant:"ghost",size:"sm",class:"text-destructive",onClick:X=>C(s)},{default:n(()=>[...e[6]||(e[6]=[p(" Revoke ",-1)])]),_:1},8,["onClick"])])]))),128))])]))]),_:1})]),_:1}),r(V,{open:c.value,title:"Create API Key",onClose:e[3]||(e[3]=s=>c.value=!1)},{footer:n(()=>[r(v,{variant:"outline",onClick:e[2]||(e[2]=s=>c.value=!1)},{default:n(()=>[p(u(d.value?"Done":"Cancel"),1)]),_:1}),d.value?j("",!0):(a(),I(v,{key:0,loading:x.value,onClick:w},{default:n(()=>[...e[10]||(e[10]=[p(" Create ",-1)])]),_:1},8,["loading"]))]),default:n(()=>[t("div",G,[d.value?(a(),l("div",J,[e[8]||(e[8]=t("p",{class:"text-xs text-emerald-400 font-medium mb-1"},"Key created — save it now!",-1)),t("p",O,u(d.value),1)])):(a(),l("div",Q,[e[9]||(e[9]=t("label",{class:"text-sm font-medium text-foreground"},"Label",-1)),r(N,{modelValue:m.value,"onUpdate:modelValue":e[1]||(e[1]=s=>m.value=s),placeholder:"e.g. claude-collector",disabled:x.value},null,8,["modelValue","disabled"])]))])]),_:1},8,["open"])]))}});export{re as default};

View file

@ -1 +0,0 @@
import{a as b}from"./admin-BRKJZipt.js";import{_ as K,a as $}from"./CardContent.vue_vue_type_script_setup_true_lang-BZS0eQer.js";import{_ as v}from"./Button.vue_vue_type_script_setup_true_lang-XMbqbqq8.js";import{_ as V}from"./Dialog.vue_vue_type_script_setup_true_lang-Bjx8yW8V.js";import{_ as N}from"./Input.vue_vue_type_script_setup_true_lang-Bo0JoDsF.js";import{_ as A,a as k}from"./utils-D_0J15Md.js";import{d as B,x as L,c as l,a as t,e as r,w as n,r as i,o as a,k as p,F as P,l as j,t as u,i as h,n as F,j as I,K as y}from"./index-yrXqsixb.js";const D={class:"p-6"},R={class:"flex items-center justify-between mb-6"},z={key:0,class:"flex items-center justify-center h-20"},M={key:1,class:"text-center text-muted-foreground py-8 text-sm"},T={key:2,class:"w-full"},U={class:"px-4 py-3 text-sm text-foreground"},E={class:"px-4 py-3 text-sm font-mono text-muted-foreground"},H={class:"px-4 py-3 text-xs text-muted-foreground"},S={class:"px-4 py-3 text-xs text-muted-foreground"},q={class:"px-4 py-3 text-right"},G={class:"space-y-4"},J={key:0,class:"rounded-md bg-emerald-500/10 border border-emerald-500/30 p-3"},O={class:"text-xs font-mono text-foreground break-all"},Q={key:1,class:"space-y-1.5"},le=B({__name:"KeysView",setup(W){const f=i([]),_=i(!1),c=i(!1),m=i(""),x=i(!1),d=i(null);L(()=>g());async function g(){_.value=!0;try{const o=await b.keys();f.value=o.data}finally{_.value=!1}}async function w(){if(m.value.trim()){x.value=!0;try{const o=await b.createKey({label:m.value});d.value=o.data.key,y.success("API key created"),await g(),m.value=""}catch{y.error("Failed to create key")}finally{x.value=!1}}}async function C(o){if(confirm(`Revoke key "${o.label}"? This cannot be undone.`))try{await b.revokeKey(o.id),y.success("Key revoked"),f.value=f.value.filter(e=>e.id!==o.id)}catch{y.error("Failed to revoke key")}}return(o,e)=>(a(),l("div",D,[t("div",R,[e[5]||(e[5]=t("h2",{class:"text-lg font-semibold text-foreground"},"API Keys",-1)),r(v,{size:"sm",onClick:e[0]||(e[0]=s=>{c.value=!0,d.value=null})},{default:n(()=>[...e[4]||(e[4]=[t("svg",{class:"h-4 w-4 mr-1.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),p(" New Key ",-1)])]),_:1})]),r(K,null,{default:n(()=>[r($,{class:"p-0"},{default:n(()=>[_.value?(a(),l("div",z,[r(A,{class:"text-primary"})])):f.value.length===0?(a(),l("div",M," No API keys ")):(a(),l("table",T,[e[7]||(e[7]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Label"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Prefix"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Created"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Last Used"),t("th",{class:"px-4 py-3"})])],-1)),t("tbody",null,[(a(!0),l(P,null,j(f.value,s=>(a(),l("tr",{key:s.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",U,u(s.label),1),t("td",E,u(s.prefix)+"...",1),t("td",H,u(h(k)(s.created_at)),1),t("td",S,u(s.last_used?h(k)(s.last_used):"Never"),1),t("td",q,[r(v,{variant:"ghost",size:"sm",class:"text-destructive",onClick:X=>C(s)},{default:n(()=>[...e[6]||(e[6]=[p(" Revoke ",-1)])]),_:1},8,["onClick"])])]))),128))])]))]),_:1})]),_:1}),r(V,{open:c.value,title:"Create API Key",onClose:e[3]||(e[3]=s=>c.value=!1)},{footer:n(()=>[r(v,{variant:"outline",onClick:e[2]||(e[2]=s=>c.value=!1)},{default:n(()=>[p(u(d.value?"Done":"Cancel"),1)]),_:1}),d.value?I("",!0):(a(),F(v,{key:0,loading:x.value,onClick:w},{default:n(()=>[...e[10]||(e[10]=[p(" Create ",-1)])]),_:1},8,["loading"]))]),default:n(()=>[t("div",G,[d.value?(a(),l("div",J,[e[8]||(e[8]=t("p",{class:"text-xs text-emerald-400 font-medium mb-1"},"Key created — save it now!",-1)),t("p",O,u(d.value),1)])):(a(),l("div",Q,[e[9]||(e[9]=t("label",{class:"text-sm font-medium text-foreground"},"Label",-1)),r(N,{modelValue:m.value,"onUpdate:modelValue":e[1]||(e[1]=s=>m.value=s),placeholder:"e.g. claude-collector",disabled:x.value},null,8,["modelValue","disabled"])]))])]),_:1},8,["open"])]))}});export{le as default};

View file

@ -1 +0,0 @@
import{E as T,r as y,d as J,u as O,x as V,c as f,a as o,p as b,i,t as v,n as $,w as x,j as k,e as C,o as c,k as w,F as B,l as F,m as z}from"./index-yrXqsixb.js";import{_ as A,a as D}from"./CardContent.vue_vue_type_script_setup_true_lang-BZS0eQer.js";import{_ as N}from"./Button.vue_vue_type_script_setup_true_lang-XMbqbqq8.js";import"./utils-D_0J15Md.js";function U(E){const e=y([]),l=y(!1),m=y(null);let s=null,r=null,u=!1;function p(){if(!u)try{s=new EventSource(E),s.onopen=()=>{l.value=!0,m.value=null},s.onmessage=n=>{try{const g=JSON.parse(n.data);e.value.push({type:"message",data:g}),e.value.length>200&&e.value.shift()}catch{e.value.push({type:"message",data:n.data})}},s.addEventListener("session_start",n=>{try{e.value.push({type:"session_start",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_start",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("session_end",n=>{try{e.value.push({type:"session_end",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_end",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("activity",n=>{try{e.value.push({type:"activity",data:JSON.parse(n.data)})}catch{e.value.push({type:"activity",data:n.data})}e.value.length>200&&e.value.shift()}),s.onerror=()=>{l.value=!1,m.value="Connection lost, reconnecting...",s==null||s.close(),s=null,u||(r=setTimeout(()=>p(),5e3))}}catch{m.value="Failed to connect to event stream",u||(r=setTimeout(()=>p(),5e3))}}function _(){u=!0,r&&clearTimeout(r),s==null||s.close(),s=null,l.value=!1}function h(){e.value=[]}return T(()=>{_()}),{events:e,connected:l,error:m,connect:p,disconnect:_,clearEvents:h}}const I={class:"p-6 h-full flex flex-col"},R={class:"flex items-center gap-3 mb-4"},M={class:"flex items-center gap-2"},P={class:"text-xs text-muted-foreground"},W={key:0,class:"mb-4 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2"},q={key:0,class:"flex items-center justify-center h-full text-sm text-muted-foreground"},G={key:1,class:"overflow-y-auto h-full font-mono text-xs"},H={class:"flex-1 min-w-0"},K={class:"flex items-center gap-2 flex-wrap"},Q={key:0,class:"text-muted-foreground"},X={class:"text-muted-foreground truncate mt-0.5"},se=J({__name:"LiveView",setup(E){const e=O(),l=e.getToken(),m=`/cc-dashboard/api/events${l?`?token=${encodeURIComponent(l)}`:""}`,{events:s,connected:r,error:u,connect:p,clearEvents:_}=U(m);V(()=>{e.isAuthenticated&&l&&p()});const h=z(()=>[...s.value].reverse().slice(0,100));function n(t){return t==="session_start"?"text-emerald-400":t==="session_end"?"text-amber-400":t==="activity"?"text-blue-400":"text-muted-foreground"}function g(t){return t==="session_start"?"▶":t==="session_end"?"■":t==="activity"?"●":"○"}function j(t){if(typeof t=="string")return t;if(t&&typeof t=="object"){const a=t;return a.message||a.summary||JSON.stringify(t)}return String(t)}function S(t){if(t&&typeof t=="object"){const a=t;return a.display_name||a.project_id||""}return""}return(t,a)=>(c(),f("div",I,[o("div",R,[a[2]||(a[2]=o("h2",{class:"text-lg font-semibold text-foreground flex-1"},"Live Feed",-1)),o("div",M,[o("div",{class:b(["h-2 w-2 rounded-full",i(r)?"bg-emerald-500 animate-pulse":"bg-red-500"])},null,2),o("span",P,v(i(r)?"Connected":"Disconnected"),1)]),i(r)?k("",!0):(c(),$(N,{key:0,variant:"outline",size:"sm",onClick:i(p)},{default:x(()=>[...a[0]||(a[0]=[w(" Reconnect ",-1)])]),_:1},8,["onClick"])),C(N,{variant:"ghost",size:"sm",onClick:i(_)},{default:x(()=>[...a[1]||(a[1]=[w(" Clear ",-1)])]),_:1},8,["onClick"])]),i(u)&&!i(r)?(c(),f("div",W,v(i(u)),1)):k("",!0),C(A,{class:"flex-1 overflow-hidden"},{default:x(()=>[C(D,{class:"p-0 h-full"},{default:x(()=>[h.value.length===0?(c(),f("div",q,[...a[3]||(a[3]=[o("div",{class:"text-center"},[o("div",{class:"text-2xl mb-2"},"📡"),o("p",null,"Waiting for events..."),o("p",{class:"text-xs mt-1"},"Activity will appear here in real-time")],-1)])])):(c(),f("div",G,[(c(!0),f(B,null,F(h.value,(d,L)=>(c(),f("div",{key:L,class:"flex items-start gap-2 px-4 py-1.5 hover:bg-muted/50 border-b border-border/30"},[o("span",{class:b([n(d.type),"shrink-0 mt-0.5"])},v(g(d.type)),3),o("div",H,[o("div",K,[o("span",{class:b([n(d.type),"font-medium"])},v(d.type),3),S(d.data)?(c(),f("span",Q,v(S(d.data)),1)):k("",!0)]),o("p",X,v(j(d.data)),1)])]))),128))]))]),_:1})]),_:1})]))}});export{se as default};

View file

@ -0,0 +1 @@
import{E as T,q as y,d as J,u as O,v as V,c as f,a as o,n as b,h as l,t as v,k as $,w as x,i as k,e as C,o as c,p as w,F as B,r as F,j as z}from"./index-DzSm5_bv.js";import{_ as A,a as D}from"./CardContent.vue_vue_type_script_setup_true_lang-B899D1fp.js";import{_ as N}from"./Button.vue_vue_type_script_setup_true_lang-D97aKlXO.js";import"./utils-7WVCegLb.js";import"./Spinner.vue_vue_type_script_setup_true_lang-DxuuceC3.js";function U(E){const e=y([]),i=y(!1),m=y(null);let s=null,r=null,u=!1;function p(){if(!u)try{s=new EventSource(E),s.onopen=()=>{i.value=!0,m.value=null},s.onmessage=n=>{try{const g=JSON.parse(n.data);e.value.push({type:"message",data:g}),e.value.length>200&&e.value.shift()}catch{e.value.push({type:"message",data:n.data})}},s.addEventListener("session_start",n=>{try{e.value.push({type:"session_start",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_start",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("session_end",n=>{try{e.value.push({type:"session_end",data:JSON.parse(n.data)})}catch{e.value.push({type:"session_end",data:n.data})}e.value.length>200&&e.value.shift()}),s.addEventListener("activity",n=>{try{e.value.push({type:"activity",data:JSON.parse(n.data)})}catch{e.value.push({type:"activity",data:n.data})}e.value.length>200&&e.value.shift()}),s.onerror=()=>{i.value=!1,m.value="Connection lost, reconnecting...",s==null||s.close(),s=null,u||(r=setTimeout(()=>p(),5e3))}}catch{m.value="Failed to connect to event stream",u||(r=setTimeout(()=>p(),5e3))}}function _(){u=!0,r&&clearTimeout(r),s==null||s.close(),s=null,i.value=!1}function h(){e.value=[]}return T(()=>{_()}),{events:e,connected:i,error:m,connect:p,disconnect:_,clearEvents:h}}const I={class:"p-6 h-full flex flex-col"},R={class:"flex items-center gap-3 mb-4"},q={class:"flex items-center gap-2"},M={class:"text-xs text-muted-foreground"},P={key:0,class:"mb-4 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2"},W={key:0,class:"flex items-center justify-center h-full text-sm text-muted-foreground"},G={key:1,class:"overflow-y-auto h-full font-mono text-xs"},H={class:"flex-1 min-w-0"},K={class:"flex items-center gap-2 flex-wrap"},Q={key:0,class:"text-muted-foreground"},X={class:"text-muted-foreground truncate mt-0.5"},ne=J({__name:"LiveView",setup(E){const e=O(),i=e.getToken(),m=`/cc-dashboard/api/events${i?`?token=${encodeURIComponent(i)}`:""}`,{events:s,connected:r,error:u,connect:p,clearEvents:_}=U(m);V(()=>{e.isAuthenticated&&i&&p()});const h=z(()=>[...s.value].reverse().slice(0,100));function n(t){return t==="session_start"?"text-emerald-400":t==="session_end"?"text-amber-400":t==="activity"?"text-blue-400":"text-muted-foreground"}function g(t){return t==="session_start"?"▶":t==="session_end"?"■":t==="activity"?"●":"○"}function j(t){if(typeof t=="string")return t;if(t&&typeof t=="object"){const a=t;return a.message||a.summary||JSON.stringify(t)}return String(t)}function S(t){if(t&&typeof t=="object"){const a=t;return a.display_name||a.project_id||""}return""}return(t,a)=>(c(),f("div",I,[o("div",R,[a[2]||(a[2]=o("h2",{class:"text-lg font-semibold text-foreground flex-1"},"Live Feed",-1)),o("div",q,[o("div",{class:b(["h-2 w-2 rounded-full",l(r)?"bg-emerald-500 animate-pulse":"bg-red-500"])},null,2),o("span",M,v(l(r)?"Connected":"Disconnected"),1)]),l(r)?k("",!0):(c(),$(N,{key:0,variant:"outline",size:"sm",onClick:l(p)},{default:x(()=>[...a[0]||(a[0]=[w(" Reconnect ",-1)])]),_:1},8,["onClick"])),C(N,{variant:"ghost",size:"sm",onClick:l(_)},{default:x(()=>[...a[1]||(a[1]=[w(" Clear ",-1)])]),_:1},8,["onClick"])]),l(u)&&!l(r)?(c(),f("div",P,v(l(u)),1)):k("",!0),C(A,{class:"flex-1 overflow-hidden"},{default:x(()=>[C(D,{class:"p-0 h-full"},{default:x(()=>[h.value.length===0?(c(),f("div",W,[...a[3]||(a[3]=[o("div",{class:"text-center"},[o("div",{class:"text-2xl mb-2"},"📡"),o("p",null,"Waiting for events..."),o("p",{class:"text-xs mt-1"},"Activity will appear here in real-time")],-1)])])):(c(),f("div",G,[(c(!0),f(B,null,F(h.value,(d,L)=>(c(),f("div",{key:L,class:"flex items-start gap-2 px-4 py-1.5 hover:bg-muted/50 border-b border-border/30"},[o("span",{class:b([n(d.type),"shrink-0 mt-0.5"])},v(g(d.type)),3),o("div",H,[o("div",K,[o("span",{class:b([n(d.type),"font-medium"])},v(d.type),3),S(d.data)?(c(),f("span",Q,v(S(d.data)),1)):k("",!0)]),o("p",X,v(j(d.data)),1)])]))),128))]))]),_:1})]),_:1})]))}});export{ne as default};

View file

@ -1 +0,0 @@
import{d as g,u as b,c as u,a as s,b as _,e as a,w as i,o as m,f as h,g as w,h as y,i as o,t as V,j as k,k as C,r as c}from"./index-yrXqsixb.js";import{_ as S}from"./Button.vue_vue_type_script_setup_true_lang-XMbqbqq8.js";import{_ as p}from"./Input.vue_vue_type_script_setup_true_lang-Bo0JoDsF.js";import{_ as N,a as j}from"./CardContent.vue_vue_type_script_setup_true_lang-BZS0eQer.js";import"./utils-D_0J15Md.js";const B={class:"min-h-screen flex items-center justify-center bg-background p-4"},$={class:"w-full max-w-sm"},q={key:0,class:"rounded-md bg-destructive/10 border border-destructive/30 px-3 py-2 text-sm text-destructive"},z={class:"space-y-1.5"},D={class:"space-y-1.5"},U=g({__name:"LoginView",setup(E){const f=h(),v=w(),t=b(),r=c(""),l=c("");async function x(){try{await t.login(r.value,l.value);const n=v.query.redirect;f.push(n??"/")}catch{}}return(n,e)=>(m(),u("div",B,[s("div",$,[e[5]||(e[5]=_('<div class="text-center mb-8"><div class="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary mb-3"><svg class="h-7 w-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg></div><h1 class="text-2xl font-bold text-foreground">CC Dashboard</h1><p class="text-sm text-muted-foreground mt-1">Corporate Planning Hub</p></div>',1)),a(N,null,{default:i(()=>[a(j,{class:"pt-6"},{default:i(()=>[s("form",{class:"space-y-4",onSubmit:y(x,["prevent"])},[o(t).error?(m(),u("div",q,V(o(t).error),1)):k("",!0),s("div",z,[e[2]||(e[2]=s("label",{for:"email",class:"text-sm font-medium text-foreground"},"Email",-1)),a(p,{id:"email",modelValue:r.value,"onUpdate:modelValue":e[0]||(e[0]=d=>r.value=d),type:"email",placeholder:"you@company.com",autocomplete:"email",disabled:o(t).loading,required:""},null,8,["modelValue","disabled"])]),s("div",D,[e[3]||(e[3]=s("label",{for:"password",class:"text-sm font-medium text-foreground"},"Password",-1)),a(p,{id:"password",modelValue:l.value,"onUpdate:modelValue":e[1]||(e[1]=d=>l.value=d),type:"password",placeholder:"••••••••",autocomplete:"current-password",disabled:o(t).loading,required:""},null,8,["modelValue","disabled"])]),a(S,{type:"submit",class:"w-full",loading:o(t).loading},{default:i(()=>[...e[4]||(e[4]=[C(" Sign in ",-1)])]),_:1},8,["loading"])],32)]),_:1})]),_:1})])]))}});export{U as default};

View file

@ -0,0 +1 @@
import{d as h,u as f,c as o,a as t,b as m,e as a,w as d,o as r,f as g,g as p,h as i,t as x,i as w}from"./index-DzSm5_bv.js";import{_ as y,a as b}from"./CardContent.vue_vue_type_script_setup_true_lang-B899D1fp.js";import"./utils-7WVCegLb.js";const v={class:"min-h-screen flex items-center justify-center bg-background p-4"},_={class:"w-full max-w-sm"},k={class:"space-y-4"},C={key:0,class:"rounded-md bg-destructive/10 border border-destructive/30 px-3 py-2 text-sm text-destructive"},B=["disabled"],V={key:0},S={key:1},M=h({__name:"LoginView",setup(F){const c=g(),l=p(),s=f();async function u(){try{await s.loginWithMicrosoft();const n=l.query.redirect;c.push(n??"/")}catch{}}return(n,e)=>(r(),o("div",v,[t("div",_,[e[2]||(e[2]=m('<div class="text-center mb-8"><div class="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary mb-3"><svg class="h-7 w-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg></div><h1 class="text-2xl font-bold text-foreground">CC Dashboard</h1><p class="text-sm text-muted-foreground mt-1">Corporate Planning Hub</p></div>',1)),a(y,null,{default:d(()=>[a(b,{class:"pt-6"},{default:d(()=>[t("div",k,[i(s).error?(r(),o("div",C,x(i(s).error),1)):w("",!0),t("button",{type:"button",disabled:i(s).loading,class:"w-full flex items-center justify-center gap-3 rounded-md border border-border bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",onClick:u},[e[0]||(e[0]=t("svg",{class:"h-5 w-5 shrink-0",viewBox:"0 0 21 21",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"1",y:"1",width:"9",height:"9",fill:"#F25022"}),t("rect",{x:"11",y:"1",width:"9",height:"9",fill:"#7FBA00"}),t("rect",{x:"1",y:"11",width:"9",height:"9",fill:"#00A4EF"}),t("rect",{x:"11",y:"11",width:"9",height:"9",fill:"#FFB900"})],-1)),i(s).loading?(r(),o("span",V,"Signing in…")):(r(),o("span",S,"Sign in with Microsoft"))],8,B),e[1]||(e[1]=t("p",{class:"text-center text-xs text-muted-foreground"}," Use your @oliver.agency account ",-1))])]),_:1})]),_:1})])]))}});export{M as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{c as r}from"./utils-D_0J15Md.js";import{d as s,o as n,c as t,p as l,i as c,a as d,A as u}from"./index-yrXqsixb.js";const h=s({__name:"Progress",props:{value:{},max:{default:100},class:{},color:{default:"default"}},setup(a){const e=a,o=()=>Math.min(100,Math.max(0,e.value/e.max*100));return(i,m)=>(n(),t("div",{class:l(c(r)("relative h-2 w-full overflow-hidden rounded-full bg-secondary",e.class))},[d("div",{class:l(["h-full rounded-full transition-all duration-300",{"bg-primary":a.color==="default","bg-emerald-500":a.color==="success","bg-amber-500":a.color==="warning","bg-red-500":a.color==="danger"}]),style:u({width:`${o()}%`})},null,6)],2))}});export{h as _};
import{c as r}from"./utils-7WVCegLb.js";import{d as s,o as n,c as t,n as l,h as c,a as d,z as u}from"./index-DzSm5_bv.js";const h=s({__name:"Progress",props:{value:{},max:{default:100},class:{},color:{default:"default"}},setup(a){const e=a,o=()=>Math.min(100,Math.max(0,e.value/e.max*100));return(i,m)=>(n(),t("div",{class:l(c(r)("relative h-2 w-full overflow-hidden rounded-full bg-secondary",e.class))},[d("div",{class:l(["h-full rounded-full transition-all duration-300",{"bg-primary":a.color==="default","bg-emerald-500":a.color==="success","bg-amber-500":a.color==="warning","bg-red-500":a.color==="danger"}]),style:u({width:`${o()}%`})},null,6)],2))}});export{h as _};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{d as p,x as g,c as r,a as s,e as d,F as v,l as y,r as _,o,n as h,w as f,t as a,j as i,i as u,p as b,f as k}from"./index-yrXqsixb.js";import{d as w}from"./dashboard-Bay5szWb.js";import{a as C,_ as $}from"./CardContent.vue_vue_type_script_setup_true_lang-BZS0eQer.js";import{_ as B}from"./Progress.vue_vue_type_script_setup_true_lang-CI2N8P-o.js";import{_ as N,f as V,a as D}from"./utils-D_0J15Md.js";const F={class:"p-6"},j={key:0,class:"flex items-center justify-center h-40"},z={key:1,class:"text-center text-muted-foreground py-12"},L={key:2,class:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"},P={class:"flex items-start justify-between gap-2 mb-3"},S={class:"min-w-0"},A={class:"font-semibold text-sm text-foreground truncate"},E={key:0,class:"text-xs text-muted-foreground truncate"},M={key:0,class:"text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded shrink-0"},R={class:"space-y-1.5"},T={class:"flex items-center justify-between text-xs"},q={class:"font-medium text-foreground"},G={class:"flex items-center justify-between text-xs"},H={class:"text-foreground"},I={key:0,class:"flex items-center justify-between text-xs"},J={class:"text-foreground"},K={key:0,class:"mt-3"},O={class:"flex items-center justify-between text-xs mb-1"},st=p({__name:"ProjectsView",setup(Q){const m=k(),l=_([]),c=_(!1);g(async()=>{c.value=!0;try{const n=await w.projects({});l.value=n.data.sort((e,t)=>t.total_hours-e.total_hours)}finally{c.value=!1}});const x=n=>n?n>90?"danger":n>70?"warning":"success":"default";return(n,e)=>(o(),r("div",F,[e[4]||(e[4]=s("h2",{class:"text-lg font-semibold text-foreground mb-6"},"Projects",-1)),c.value?(o(),r("div",j,[d(N,{size:"lg",class:"text-primary"})])):l.value.length===0?(o(),r("div",z," No projects found ")):(o(),r("div",L,[(o(!0),r(v,null,y(l.value,t=>(o(),h($,{key:t.project_id,class:"cursor-pointer hover:border-primary/50 transition-colors",onClick:U=>u(m).push(`/projects/${t.project_id}`)},{default:f(()=>[d(C,{class:"p-4"},{default:f(()=>[s("div",P,[s("div",S,[s("p",A,a(t.display_name),1),t.client?(o(),r("p",E,a(t.client),1)):i("",!0)]),t.job_number?(o(),r("span",M,a(t.job_number),1)):i("",!0)]),s("div",R,[s("div",T,[e[0]||(e[0]=s("span",{class:"text-muted-foreground"},"Total hours",-1)),s("span",q,a(u(V)(t.total_hours)),1)]),s("div",G,[e[1]||(e[1]=s("span",{class:"text-muted-foreground"},"Sessions",-1)),s("span",H,a(t.session_count),1)]),t.last_active?(o(),r("div",I,[e[2]||(e[2]=s("span",{class:"text-muted-foreground"},"Last active",-1)),s("span",J,a(u(D)(t.last_active)),1)])):i("",!0)]),t.progress_pct!==null?(o(),r("div",K,[s("div",O,[e[3]||(e[3]=s("span",{class:"text-muted-foreground"},"Budget",-1)),s("span",{class:b(t.progress_pct>90?"text-red-400":"text-muted-foreground")},a((t.progress_pct??0).toFixed(0))+"% ",3)]),d(B,{value:t.progress_pct,color:x(t.progress_pct)},null,8,["value","color"])])):i("",!0)]),_:2},1024)]),_:2},1032,["onClick"]))),128))]))]))}});export{st as default};

View file

@ -0,0 +1 @@
import{d as p,v as g,c as r,a as s,e as d,F as v,r as y,q as _,o,k as h,w as f,t as a,i,h as u,n as b,f as k}from"./index-DzSm5_bv.js";import{d as w}from"./dashboard-uOtmhTNc.js";import{a as C,_ as $}from"./CardContent.vue_vue_type_script_setup_true_lang-B899D1fp.js";import{_ as B}from"./Progress.vue_vue_type_script_setup_true_lang-DK67Z5Fm.js";import{_ as N}from"./Spinner.vue_vue_type_script_setup_true_lang-DxuuceC3.js";import{f as V,a as D}from"./utils-7WVCegLb.js";const F={class:"p-6"},z={key:0,class:"flex items-center justify-center h-40"},L={key:1,class:"text-center text-muted-foreground py-12"},P={key:2,class:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"},S={class:"flex items-start justify-between gap-2 mb-3"},j={class:"min-w-0"},q={class:"font-semibold text-sm text-foreground truncate"},A={key:0,class:"text-xs text-muted-foreground truncate"},E={key:0,class:"text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded shrink-0"},M={class:"space-y-1.5"},R={class:"flex items-center justify-between text-xs"},T={class:"font-medium text-foreground"},G={class:"flex items-center justify-between text-xs"},H={class:"text-foreground"},I={key:0,class:"flex items-center justify-between text-xs"},J={class:"text-foreground"},K={key:0,class:"mt-3"},O={class:"flex items-center justify-between text-xs mb-1"},et=p({__name:"ProjectsView",setup(Q){const m=k(),l=_([]),c=_(!1);g(async()=>{c.value=!0;try{const n=await w.projects({});l.value=n.data.sort((e,t)=>t.total_hours-e.total_hours)}finally{c.value=!1}});const x=n=>n?n>90?"danger":n>70?"warning":"success":"default";return(n,e)=>(o(),r("div",F,[e[4]||(e[4]=s("h2",{class:"text-lg font-semibold text-foreground mb-6"},"Projects",-1)),c.value?(o(),r("div",z,[d(N,{size:"lg",class:"text-primary"})])):l.value.length===0?(o(),r("div",L," No projects found ")):(o(),r("div",P,[(o(!0),r(v,null,y(l.value,t=>(o(),h($,{key:t.project_id,class:"cursor-pointer hover:border-primary/50 transition-colors",onClick:U=>u(m).push(`/projects/${t.project_id}`)},{default:f(()=>[d(C,{class:"p-4"},{default:f(()=>[s("div",S,[s("div",j,[s("p",q,a(t.display_name),1),t.client?(o(),r("p",A,a(t.client),1)):i("",!0)]),t.job_number?(o(),r("span",E,a(t.job_number),1)):i("",!0)]),s("div",M,[s("div",R,[e[0]||(e[0]=s("span",{class:"text-muted-foreground"},"Total hours",-1)),s("span",T,a(u(V)(t.total_hours)),1)]),s("div",G,[e[1]||(e[1]=s("span",{class:"text-muted-foreground"},"Sessions",-1)),s("span",H,a(t.session_count),1)]),t.last_active?(o(),r("div",I,[e[2]||(e[2]=s("span",{class:"text-muted-foreground"},"Last active",-1)),s("span",J,a(u(D)(t.last_active)),1)])):i("",!0)]),t.progress_pct!==null?(o(),r("div",K,[s("div",O,[e[3]||(e[3]=s("span",{class:"text-muted-foreground"},"Budget",-1)),s("span",{class:b(t.progress_pct>90?"text-red-400":"text-muted-foreground")},a((t.progress_pct??0).toFixed(0))+"% ",3)]),d(B,{value:t.progress_pct,color:x(t.progress_pct)},null,8,["value","color"])])):i("",!0)]),_:2},1024)]),_:2},1032,["onClick"]))),128))]))]))}});export{et as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{d as l,o as n,c as o,n as t,a as r}from"./index-DzSm5_bv.js";const i=l({__name:"Spinner",props:{size:{},class:{}},setup(s){return(a,e)=>(n(),o("svg",{class:t(["animate-spin text-current",s.size==="sm"?"h-3 w-3":s.size==="lg"?"h-6 w-6":"h-4 w-4",a.$props.class]),xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},[...e[0]||(e[0]=[r("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),r("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"},null,-1)])],2))}});export{i as _};

View file

@ -1 +1 @@
import{D as e}from"./index-yrXqsixb.js";const i={users:()=>e.get("/api/admin/users"),keys:()=>e.get("/api/keys"),createKey:s=>e.post("/api/keys",s),revokeKey:s=>e.delete(`/api/keys/${s}`)};export{i as a};
import{D as e}from"./index-DzSm5_bv.js";const i={users:()=>e.get("/api/admin/users"),keys:()=>e.get("/api/keys"),createKey:s=>e.post("/api/keys",s),revokeKey:s=>e.delete(`/api/keys/${s}`)};export{i as a};

View file

@ -1 +1 @@
import{D as t}from"./index-yrXqsixb.js";const e={summary:a=>t.get("/api/dashboard/summary",{params:a}),projects:a=>t.get("/api/dashboard/projects",{params:a}),timeline:a=>t.get("/api/dashboard/timeline",{params:a}),monthly:a=>t.get("/api/dashboard/monthly",{params:a}),dow:a=>t.get("/api/dashboard/dow",{params:a}),tools:a=>t.get("/api/dashboard/tools",{params:a}),activity:a=>t.get("/api/dashboard/activity",{params:a}),calendar:a=>t.get("/api/dashboard/calendar",{params:a}),project:(a,o)=>t.get("/api/dashboard/project/"+a,{params:o})};export{e as d};
import{D as t}from"./index-DzSm5_bv.js";const e={summary:a=>t.get("/api/dashboard/summary",{params:a}),projects:a=>t.get("/api/dashboard/projects",{params:a}),timeline:a=>t.get("/api/dashboard/timeline",{params:a}),monthly:a=>t.get("/api/dashboard/monthly",{params:a}),dow:a=>t.get("/api/dashboard/dow",{params:a}),tools:a=>t.get("/api/dashboard/tools",{params:a}),activity:a=>t.get("/api/dashboard/activity",{params:a}),calendar:a=>t.get("/api/dashboard/calendar",{params:a}),project:(a,o)=>t.get("/api/dashboard/project/"+a,{params:o})};export{e as d};

View file

@ -1 +1 @@
import{D as s,B as I,r as o}from"./index-yrXqsixb.js";const i={getIntegration:()=>s.get("/api/devops/integration"),saveIntegration:e=>s.put("/api/devops/integration",e),deleteIntegration:()=>s.delete("/api/devops/integration"),sync:()=>s.post("/api/devops/sync"),workItems:e=>s.get("/api/devops/work-items",{params:e?{state:e}:void 0})},m=I("devops",()=>{const e=o(null),r=o([]),l=o(!1),n=o(!1),c=o(null);async function u(){n.value=!0;try{const t=await i.getIntegration();e.value=t.data}catch{e.value=null}finally{n.value=!1}}async function d(t){const a=await i.saveIntegration(t);e.value=a.data}async function g(){await i.deleteIntegration(),e.value=null}async function f(){var t,a;l.value=!0,c.value=null;try{await i.sync(),await u()}catch(v){const p=v;throw c.value=((a=(t=p.response)==null?void 0:t.data)==null?void 0:a.detail)??p.message??"Sync failed",v}finally{l.value=!1}}async function y(t){n.value=!0;try{const a=await i.workItems(t);r.value=a.data}catch{r.value=[]}finally{n.value=!1}}return{integration:e,workItems:r,syncing:l,loading:n,error:c,fetchIntegration:u,saveIntegration:d,deleteIntegration:g,sync:f,fetchWorkItems:y}});export{m as u};
import{D as s,A as I,q as o}from"./index-DzSm5_bv.js";const i={getIntegration:()=>s.get("/api/devops/integration"),saveIntegration:e=>s.put("/api/devops/integration",e),deleteIntegration:()=>s.delete("/api/devops/integration"),sync:()=>s.post("/api/devops/sync"),workItems:e=>s.get("/api/devops/work-items",{params:e?{state:e}:void 0})},m=I("devops",()=>{const e=o(null),l=o([]),r=o(!1),n=o(!1),c=o(null);async function u(){n.value=!0;try{const t=await i.getIntegration();e.value=t.data}catch{e.value=null}finally{n.value=!1}}async function d(t){const a=await i.saveIntegration(t);e.value=a.data}async function g(){await i.deleteIntegration(),e.value=null}async function f(){var t,a;r.value=!0,c.value=null;try{await i.sync(),await u()}catch(v){const p=v;throw c.value=((a=(t=p.response)==null?void 0:t.data)==null?void 0:a.detail)??p.message??"Sync failed",v}finally{r.value=!1}}async function y(t){n.value=!0;try{const a=await i.workItems(t);l.value=a.data}catch{l.value=[]}finally{n.value=!1}}return{integration:e,workItems:l,syncing:r,loading:n,error:c,fetchIntegration:u,saveIntegration:d,deleteIntegration:g,sync:f,fetchWorkItems:y}});export{m as u};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -14,8 +14,8 @@
else { document.documentElement.classList.remove('dark'); }
})();
</script>
<script type="module" crossorigin src="/cc-dashboard/static/assets/index-yrXqsixb.js"></script>
<link rel="stylesheet" crossorigin href="/cc-dashboard/static/assets/index-BXlrCgg3.css">
<script type="module" crossorigin src="/cc-dashboard/static/assets/index-DzSm5_bv.js"></script>
<link rel="stylesheet" crossorigin href="/cc-dashboard/static/assets/index-C-CL-7fz.css">
</head>
<body>
<div id="app"></div>

5
web/.env.example Normal file
View file

@ -0,0 +1,5 @@
# Azure AD SSO
VITE_AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
VITE_AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
VITE_AZURE_REDIRECT_URI=https://optical-dev.oliver.solutions/cc-dashboard/
# For local dev, change redirect URI to: http://localhost:5173/

5924
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,8 @@
"tailwind-merge": "^2.4.0",
"radix-vue": "^1.9.9",
"@radix-icons/vue": "^1.0.0",
"lucide-vue-next": "^0.427.0"
"lucide-vue-next": "^0.427.0",
"@azure/msal-browser": "^3.20.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",

18
web/src/api/msal.ts Normal file
View file

@ -0,0 +1,18 @@
/// <reference types="vite/client" />
import { PublicClientApplication } from '@azure/msal-browser'
export const msalInstance = new PublicClientApplication({
auth: {
clientId: import.meta.env.VITE_AZURE_CLIENT_ID as string,
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AZURE_TENANT_ID}`,
redirectUri: import.meta.env.VITE_AZURE_REDIRECT_URI as string,
},
cache: { cacheLocation: 'sessionStorage', storeAuthStateInCookie: false },
})
export const LOGIN_SCOPES = ['openid', 'profile', 'email']
export async function initMsal(): Promise<void> {
await msalInstance.initialize()
await msalInstance.handleRedirectPromise()
}

View file

@ -5,23 +5,26 @@ import App from './App.vue'
import router from './router'
import { setupInterceptors } from './api/client'
import { useAuthStore } from './stores/auth'
import { initMsal } from './api/msal'
import './styles/globals.css'
const app = createApp(App)
const pinia = createPinia()
// Initialize MSAL before mounting — handles redirect callback if present
initMsal().then(() => {
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(VueQueryPlugin)
app.use(pinia)
app.use(router)
app.use(VueQueryPlugin)
// Setup axios interceptors after pinia is installed
const authStore = useAuthStore()
setupInterceptors(
() => authStore.getToken(),
() => {
authStore.logout()
router.push({ name: 'login' })
}
)
const authStore = useAuthStore()
setupInterceptors(
() => authStore.getToken(),
() => {
authStore.logout()
router.push({ name: 'login' })
}
)
app.mount('#app')
app.mount('#app')
})

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import apiClient from '@/api/client'
import { msalInstance, LOGIN_SCOPES } from '@/api/msal'
import type { UserOut } from '@/types'
export const useAuthStore = defineStore('auth', () => {
@ -13,28 +14,35 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => token.value !== null)
const isAdmin = computed(() => user.value?.role === 'admin')
async function login(email: string, password: string): Promise<void> {
async function loginWithMicrosoft(): Promise<void> {
loading.value = true
error.value = null
try {
const res = await apiClient.post<{ access_token: string; token_type: string }>(
'/api/auth/login',
{ email, password }
const result = await msalInstance.loginPopup({ scopes: LOGIN_SCOPES })
const idToken = result.idToken
const res = await apiClient.post<{ access_token: string; refresh_token: string }>(
'/api/auth/microsoft',
{ id_token: idToken }
)
token.value = res.data.access_token
await fetchMe()
} catch (err: unknown) {
const axiosError = err as { response?: { data?: { detail?: string } } }
error.value = axiosError.response?.data?.detail ?? 'Login failed'
const axiosError = err as { response?: { data?: { detail?: string } }; message?: string }
error.value = axiosError.response?.data?.detail ?? axiosError.message ?? 'Login failed'
throw err
} finally {
loading.value = false
}
}
function logout(): void {
async function logout(): Promise<void> {
token.value = null
user.value = null
try {
await msalInstance.clearCache()
} catch {
// clearCache may fail if no active account — safe to ignore
}
}
async function fetchMe(): Promise<void> {
@ -53,7 +61,7 @@ export const useAuthStore = defineStore('auth', () => {
error,
isAuthenticated,
isAdmin,
login,
loginWithMicrosoft,
logout,
fetchMe,
getToken,

View file

@ -1,9 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Card from '@/components/ui/Card.vue'
import CardContent from '@/components/ui/CardContent.vue'
@ -11,12 +8,9 @@ const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
async function handleLogin() {
try {
await authStore.login(email.value, password.value)
await authStore.loginWithMicrosoft()
const redirect = route.query.redirect as string | undefined
router.push(redirect ?? '/')
} catch {
@ -42,7 +36,7 @@ async function handleLogin() {
<Card>
<CardContent class="pt-6">
<form class="space-y-4" @submit.prevent="handleLogin">
<div class="space-y-4">
<!-- Error -->
<div
v-if="authStore.error"
@ -51,40 +45,28 @@ async function handleLogin() {
{{ authStore.error }}
</div>
<div class="space-y-1.5">
<label for="email" class="text-sm font-medium text-foreground">Email</label>
<Input
id="email"
v-model="email"
type="email"
placeholder="you@company.com"
autocomplete="email"
:disabled="authStore.loading"
required
/>
</div>
<div class="space-y-1.5">
<label for="password" class="text-sm font-medium text-foreground">Password</label>
<Input
id="password"
v-model="password"
type="password"
placeholder="••••••••"
autocomplete="current-password"
:disabled="authStore.loading"
required
/>
</div>
<Button
type="submit"
class="w-full"
:loading="authStore.loading"
<!-- Microsoft Sign In button -->
<button
type="button"
:disabled="authStore.loading"
class="w-full flex items-center justify-center gap-3 rounded-md border border-border bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
@click="handleLogin"
>
Sign in
</Button>
</form>
<!-- Microsoft logo -->
<svg class="h-5 w-5 shrink-0" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="9" height="9" fill="#F25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7FBA00"/>
<rect x="1" y="11" width="9" height="9" fill="#00A4EF"/>
<rect x="11" y="11" width="9" height="9" fill="#FFB900"/>
</svg>
<span v-if="authStore.loading">Signing in</span>
<span v-else>Sign in with Microsoft</span>
</button>
<p class="text-center text-xs text-muted-foreground">
Use your @oliver.agency account
</p>
</div>
</CardContent>
</Card>
</div>