Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/admin/src/features/auth/hooks/useLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router";
import { HTTPError } from "ky";
import { useState } from "react";
import toast from "react-hot-toast";
import { api } from "../../../utils/api";
import { api, setTokens } from "../../../utils/api";
export const useLogin = () => {
const navigate = useNavigate();
const [username, setUsername] = useState("");
Expand All @@ -21,7 +21,7 @@ export const useLogin = () => {
return res;
},
onSuccess: async (data) => {
localStorage.setItem("token", data.access_token);
setTokens(data.access_token, data.refresh_token);

// Check if user is admin
try {
Expand Down
34 changes: 19 additions & 15 deletions apps/admin/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ function RouteComponent() {
validationErrors,
} = useRegister();

const showRegisterForm = !installationStatus?.is_installed;

const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (showRegisterForm) {
register();
} else {
login();
}
};

if (isInstallationLoading) {
return (
<main className="m-auto max-w-sm">
Expand All @@ -75,8 +86,6 @@ function RouteComponent() {
);
}

const showRegisterForm = !installationStatus?.is_installed;

return (
<main className="m-auto max-w-sm">
<div className="flex h-screen flex-col justify-center gap-10">
Expand Down Expand Up @@ -104,7 +113,10 @@ function RouteComponent() {
)}
</div>
</section>
<div className="space-y-6 rounded-xl border border-border p-8 shadow-2xl">
<form
onSubmit={handleFormSubmit}
className="space-y-6 rounded-xl border border-border p-8 shadow-2xl"
>
<section className="space-y-3">
{showRegisterForm ? (
<>
Expand Down Expand Up @@ -162,11 +174,7 @@ function RouteComponent() {
</p>
)}
</section>
<Button
radius="xl"
className="mt-2 w-full"
onClick={() => register()}
>
<Button type="submit" radius="xl" className="mt-2 w-full">
Create Admin Account
</Button>
</>
Expand All @@ -187,17 +195,13 @@ function RouteComponent() {
onChange={(v) => setPassword(v.target.value)}
/>
</section>
<Button
radius="xl"
className="mt-2 w-full"
onClick={() => login()}
>
<Button type="submit" radius="xl" className="mt-2 w-full">
Login
</Button>
</>
)}
</section>{" "}
</div>
</section>
</form>
</div>
</main>
);
Expand Down
70 changes: 66 additions & 4 deletions apps/admin/src/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,77 @@
import { createApi } from "@opencircle/core";
import ky from "ky";

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api";

let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;

async function refreshAccessToken(): Promise<string | null> {
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) {
return null;
}

try {
const response = await ky
.post(`${API_URL}/auth/refresh`, {
json: { refresh_token: refreshToken },
})
.json<{ access_token: string; token_type: string }>();

localStorage.setItem("token", response.access_token);
return response.access_token;
} catch {
localStorage.removeItem("token");
localStorage.removeItem("refreshToken");
return null;
}
}

async function getValidAccessToken(): Promise<string | null> {
if (isRefreshing) {
return refreshPromise;
}

isRefreshing = true;
refreshPromise = refreshAccessToken();
const token = await refreshPromise;
isRefreshing = false;
refreshPromise = null;
return token;
}

export const api = createApi(API_URL, {
beforeRequest: [
(request) => {
request.headers.set(
"Authorization",
`Bearer ${localStorage.getItem("token")}`,
);
const token = localStorage.getItem("token");
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
return request;
},
],
afterResponse: [
async (request, _options, response) => {
if (response.status === 401 && !request.url.includes("/auth/refresh")) {
const newToken = await getValidAccessToken();
if (newToken) {
request.headers.set("Authorization", `Bearer ${newToken}`);
return ky(request);
}
window.location.href = "/";
}
return response;
},
],
});

export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem("token", accessToken);
localStorage.setItem("refreshToken", refreshToken);
}

export function clearTokens() {
localStorage.removeItem("token");
localStorage.removeItem("refreshToken");
}
2 changes: 1 addition & 1 deletion apps/api/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from alembic import context
from sqlmodel import SQLModel
from src.database.models import Role, PostType, User, Post, Media, Channel, ChannelMember, ChannelType
from src.database.models import Role, PostType, User, Post, Media, Channel, ChannelMember, ChannelType, RefreshToken, RefreshTokenStatus
from src.core.settings import settings

# this is the Alembic Config object, which provides
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Adding refresh token model

Revision ID: 1fba4de46b5b
Revises: 21a853626d70
Create Date: 2025-11-27 09:30:28.046594

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel



# revision identifiers, used by Alembic.
revision: str = '1fba4de46b5b'
down_revision: Union[str, Sequence[str], None] = '21a853626d70'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('refresh_token',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sa.Enum('ACTIVE', 'REVOKED', 'EXPIRED', name='refreshtokenstatus'), nullable=False),
sa.Column('expires_at', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_refresh_token_token_hash'), 'refresh_token', ['token_hash'], unique=True)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_refresh_token_token_hash'), table_name='refresh_token')
op.drop_table('refresh_token')
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""add_device_info_to_refresh_token

Revision ID: d307b309adfc
Revises: 1fba4de46b5b
Create Date: 2025-11-27 09:39:24.585674

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel



# revision identifiers, used by Alembic.
revision: str = 'd307b309adfc'
down_revision: Union[str, Sequence[str], None] = '1fba4de46b5b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('refresh_token', sa.Column('device_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('refresh_token', sa.Column('device_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('refresh_token', sa.Column('browser', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('refresh_token', sa.Column('os', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('refresh_token', sa.Column('ip_address', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('refresh_token', sa.Column('last_used_at', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('refresh_token', 'last_used_at')
op.drop_column('refresh_token', 'ip_address')
op.drop_column('refresh_token', 'os')
op.drop_column('refresh_token', 'browser')
op.drop_column('refresh_token', 'device_type')
op.drop_column('refresh_token', 'device_name')
# ### end Alembic commands ###
47 changes: 47 additions & 0 deletions apps/api/src/api/account/api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from typing import List

import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from sqlmodel import Session, select

from src.api.auth.serializer import (
RevokeSessionRequest,
RevokeSessionResponse,
SessionResponse,
)
from src.core.settings import settings
from src.database.engine import get_session as get_db
from src.database.models import Role, User, UserSettings, UserSocial
from src.modules.auth.auth_methods import get_user_sessions, revoke_session_by_id
from src.modules.user.user_methods import get_user_by_username

from .serializer import UserResponse
Expand Down Expand Up @@ -129,3 +137,42 @@ def get_account(
db.refresh(user_with_data)

return UserResponse.model_validate(user_with_data)


@router.get("/account/sessions", response_model=List[SessionResponse])
def get_sessions(
current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
):
"""Get all active sessions for the current user."""
sessions = get_user_sessions(db, current_user.id)
return [
SessionResponse(
id=session.id,
device_name=session.device_name,
device_type=session.device_type,
browser=session.browser,
os=session.os,
ip_address=session.ip_address,
created_at=session.created_at.isoformat()
if hasattr(session.created_at, "isoformat")
else str(session.created_at),
last_used_at=session.last_used_at,
)
for session in sessions
]


@router.post("/account/sessions/revoke", response_model=RevokeSessionResponse)
def revoke_session(
request: RevokeSessionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Revoke a specific session by ID."""
success = revoke_session_by_id(db, request.session_id, current_user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found or already revoked",
)
return RevokeSessionResponse(message="Session revoked successfully")
Loading