sessions, or something

This commit is contained in:
2025-08-09 16:28:49 +02:00
parent 4c70a3f72d
commit 21d5f56a39
30 changed files with 683 additions and 38 deletions
+12 -2
View File
@@ -8,9 +8,19 @@ https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from muzak.auth import QueryAuthMiddleware
import voting.routing
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'muzak.settings')
application = get_asgi_application()
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": QueryAuthMiddleware(
URLRouter(
voting.routing.websocket_urlpatterns
)
),
})
+46 -11
View File
@@ -1,7 +1,19 @@
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from django.contrib.auth.models import User
from django.contrib.auth.models import User, AnonymousUser
from channels.db import database_sync_to_async
def get_user_from_token(token, log=False):
if log:
print("verifying token:", token)
backend = OIDCAuthenticationBackend()
try:
claims = backend.verify_token(token)
except Exception as e:
raise AuthenticationFailed(f'Invalid token: {e}')
return backend.filter_users_by_claims(claims).first()
class OIDCBearerTokenAuthentication(BaseAuthentication):
def authenticate(self, request):
@@ -9,15 +21,38 @@ class OIDCBearerTokenAuthentication(BaseAuthentication):
if not auth.startswith('Bearer '):
return None
token = auth.split(' ')[1]
backend = OIDCAuthenticationBackend()
try:
claims = backend.verify_token(token)
except Exception as e:
raise AuthenticationFailed(f'Invalid token: {e}')
user = backend.filter_users_by_claims(claims).first()
user = get_user_from_token(token)
if not user:
raise AuthenticationFailed(f'Unknown user: {claims}')
raise AuthenticationFailed(f'Unknown user: {token}')
return (user, None)
# @database_sync_to_async
# def get_user(user_id):
# try:
# return User.objects.get(id=user_id)
# except User.DoesNotExist:
# return AnonymousUser()
class QueryAuthMiddleware:
"""
Custom middleware that takes user from passed token.
"""
def __init__(self, app):
# Store the ASGI application we were passed
self.app = app
async def __call__(self, scope, receive, send):
scope['user'] = AnonymousUser()
# print("Authing", scope)
# data = await receive()
# print("data: ", data)
# token = data.get('token')
# if token:
# user = database_sync_to_async(get_user_from_token(token, print=True))
# print("USER?", user)
# if user:
# scope['user'] = user
# else:
# pass
return await self.app(scope, receive, send)
+14 -1
View File
@@ -18,7 +18,9 @@ CORS_ALLOW_ALL_ORIGINS = True
CURRENT_HOST = 'http://127.0.0.1:8000' # Needs to be set for spotify callback to work.
INSTALLED_APPS = [
'daphne',
'playlist.apps.PlaylistConfig',
'voting.apps.VotingConfig',
'django.contrib.admin',
'django.contrib.auth',
'mozilla_django_oidc',
@@ -30,6 +32,7 @@ INSTALLED_APPS = [
'rest_framework',
'corsheaders',
'drf_spectacular',
'channels',
]
MIDDLEWARE = [
@@ -48,7 +51,7 @@ ROOT_URLCONF = 'muzak.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -62,6 +65,16 @@ TEMPLATES = [
]
WSGI_APPLICATION = 'muzak.wsgi.application'
ASGI_APPLICATION = 'muzak.asgi.application'
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("valkey", 6379)],
},
},
}
DATABASES = {
'default': {
+1
View File
@@ -20,5 +20,6 @@ from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('oidc/', include('mozilla_django_oidc.urls')),
path('voting/', include('voting.urls')),
path('', include('playlist.urls')),
]
+4 -1
View File
@@ -1,8 +1,10 @@
from django.contrib import admin
from django.utils.http import urlencode
from django.shortcuts import redirect
from .models import Track, Artist, Album, Background, Vote, Profile
from .models import Track, Artist, Album, Background, Vote, Profile, Session
class SessionAdmin(admin.ModelAdmin):
pass
class TrackAdmin(admin.ModelAdmin):
pass
class ArtistAdmin(admin.ModelAdmin):
@@ -34,3 +36,4 @@ admin.site.register(Album, AlbumAdmin)
admin.site.register(Background, BackgroundAdmin)
admin.site.register(Vote, VoteAdmin)
admin.site.register(Profile, ProfileAdmin)
admin.site.register(Session, SessionAdmin)
@@ -0,0 +1,98 @@
# Generated by Django 5.2.4 on 2025-08-07 17:36
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("playlist", "0022_background_cover"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="vote",
name="points",
field=models.IntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
]
),
),
migrations.CreateModel(
name="Session",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("opens", models.DateTimeField(default=None, null=True)),
("session_id", models.UUIDField(default=uuid.uuid4, editable=False)),
("seed", models.IntegerField(default=892933)),
(
"status",
models.CharField(
choices=[
("lobby", "Lobby"),
("voting", "Voting"),
("waiting_for_next_track", "Waiting for Next Track"),
("paused", "Paused"),
("voting_done", "Voting Done"),
],
default="lobby",
max_length=30,
),
),
("voting_open", models.BooleanField(default=False)),
(
"current_track",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="playlist.track",
),
),
(
"host",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"players_connected",
models.ManyToManyField(
related_name="sessions_joined", to=settings.AUTH_USER_MODEL
),
),
(
"players_wanted",
models.ManyToManyField(
related_name="players_wanted", to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.AddField(
model_name="vote",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="playlist.session",
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-09 06:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("playlist", "0023_alter_vote_points_session_vote_session"),
]
operations = [
migrations.AlterField(
model_name="session",
name="seed",
field=models.IntegerField(default=689663),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 5.2.4 on 2025-08-09 08:42
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("playlist", "0024_alter_session_seed"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="session",
name="players_connected",
field=models.ManyToManyField(
null=True, related_name="players_joined", to=settings.AUTH_USER_MODEL
),
),
migrations.AlterField(
model_name="session",
name="seed",
field=models.IntegerField(default=804599),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 5.2.4 on 2025-08-09 08:42
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("playlist", "0025_alter_session_players_connected_alter_session_seed"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="session",
name="players_connected",
field=models.ManyToManyField(
blank=True, related_name="players_joined", to=settings.AUTH_USER_MODEL
),
),
migrations.AlterField(
model_name="session",
name="seed",
field=models.IntegerField(default=456463),
),
]
+1
View File
@@ -4,3 +4,4 @@ from .album import Album
from .vote import Vote
from .profile import Profile, Background
from .playlist import Playlist
from .session import Session
+33
View File
@@ -0,0 +1,33 @@
from django.db import models
from django.contrib.auth.models import User
import uuid
import random
from datetime import datetime
class Session(models.Model):
def __str__(self):
return f"Session {self.session_id} - Host: {self.host.username}"
@property
def is_open(self):
return datetime.now() < self.opens
opens = models.DateTimeField(null=True, default=None)
session_id = models.UUIDField(default=uuid.uuid4, editable=False)
host = models.ForeignKey(User, on_delete=models.CASCADE)
players_wanted = models.ManyToManyField(User, related_name='players_wanted')
players_connected= models.ManyToManyField(User, related_name='players_joined', blank=True)
seed = models.IntegerField(default=random.randint(1, 1000000))
status = models.CharField(
max_length=30,
choices=[
('lobby', 'Lobby'),
('voting', 'Voting'),
('waiting_for_next_track', 'Waiting for Next Track'),
('paused', 'Paused'),
('voting_done', 'Voting Done'),
],
default='lobby'
)
current_track = models.ForeignKey('Track', on_delete=models.SET_NULL, null=True, blank=True)
voting_open = models.BooleanField(default=False)
+2
View File
@@ -1,6 +1,7 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
from .session import Session
class Vote(models.Model):
class Meta:
@@ -9,3 +10,4 @@ class Vote(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
points = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)])
skipped = models.BooleanField(default=False)
session = models.ForeignKey(Session, on_delete=models.CASCADE, null=True, default=None)
+1
View File
@@ -4,3 +4,4 @@ from .user import *
from .album import *
from .profile import *
from .vote import *
from .session import *
+9
View File
@@ -0,0 +1,9 @@
from rest_framework import serializers
from playlist.models import Session
class SessionSerializer(serializers.ModelSerializer):
host = serializers.EmailField(source='host.email', read_only=True)
class Meta:
model = Session
fields = '__all__'
+3
View File
@@ -11,3 +11,6 @@ markdown
PyYAML
Pygments
drf-spectacular
channels
daphne
channels-redis
View File
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class VotingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'voting'
+71
View File
@@ -0,0 +1,71 @@
import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
from django.contrib.auth.models import User
from muzak.auth import get_user_from_token
from channels.db import database_sync_to_async
class SessionConsumer(WebsocketConsumer):
def connect(self):
self.session_id = self.scope["url_route"]["kwargs"]["session_id"]
self.session_name = f"session_{self.session_id}"
# print("---")
# print(self.scope)
# Join room group
async_to_sync(self.channel_layer.group_add)(
self.session_name, self.channel_name
)
self.accept()
def disconnect(self, close_code):
# Leave room group
async_to_sync(self.channel_layer.group_discard)(
self.session_name, self.channel_name
)
# Receive message from WebSocket
def receive(self, text_data):
user = self.scope["user"]
text_data_json = json.loads(text_data)
action = text_data_json["action"]
# Niet lachen, middleware is moeilijk oké?
username = text_data_json.get("username")
token = text_data_json.get("token")
if token:
user = get_user_from_token(token, log=True)
print(user)
elif username:
pass
#user = User.objects.get(username=username)
if action == "message":
message = text_data_json["message"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.session_name, {"type": "chat.message", "message": str(user) + ": " + message}
)
if action == "login":
print("LOGIN", str(user))
async_to_sync(self.channel_layer.group_send)(
self.session_name, {"type": "announce", "user": str(user)}
)
def announce(self, event):
user = event["user"]
self.send(text_data=json.dumps({"joined": user}))
# Receive message from room group
def chat_message(self, event):
message = event["message"]
print(self.scope["user"])
# Send message to WebSocket
self.send(text_data=json.dumps({"message": message}))
+7
View File
@@ -0,0 +1,7 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"voting/session/(?P<session_id>[\w\-]+)/$", consumers.SessionConsumer.as_asgi()),
]
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Sessions</title>
</head>
<body>
<input id="room-name-input" type="text" size="100" /><br />
<input id="room-name-submit" type="button" value="Enter" />
<script>
document.querySelector("#room-name-input").focus();
document.querySelector("#room-name-input").onkeyup = function (e) {
if (e.key === "Enter") {
// enter, return
document.querySelector("#room-name-submit").click();
}
};
document.querySelector("#room-name-submit").onclick = function (e) {
var roomName = document.querySelector("#room-name-input").value;
window.location.pathname =
"/voting/session/" + roomName + "/test";
};
</script>
</body>
</html>
@@ -0,0 +1,63 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Votign Session</title>
</head>
<body>
<textarea id="chat-log" cols="100" rows="20"></textarea><br />
<input id="chat-message-input" type="text" size="100" /><br />
<input id="chat-message-submit" type="button" value="Send" />
{{ room_name|json_script:"room-name" }}
<script>
const roomName = JSON.parse(
document.getElementById("room-name").textContent,
);
const host =
"ws://" +
window.location.host +
"/voting/session/" +
roomName +
"/";
const chatSocket = new WebSocket(host);
console.log(host);
chatSocket.onmessage = function (e) {
const data = JSON.parse(e.data);
document.querySelector("#chat-log").value +=
data.message + "\n";
};
chatSocket.onclose = function (e) {
console.error("Chat socket closed unexpectedly");
};
document.querySelector("#chat-message-input").focus();
document.querySelector("#chat-message-input").onkeyup = function (
e,
) {
if (e.key === "Enter") {
// enter, return
document.querySelector("#chat-message-submit").click();
}
};
document.querySelector("#chat-message-submit").onclick = function (
e,
) {
const messageInputDom = document.querySelector(
"#chat-message-input",
);
const message = messageInputDom.value;
chatSocket.send(
JSON.stringify({
action: "message",
message: message,
}),
);
messageInputDom.value = "";
};
</script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("session/<str:session_id>/test", views.session, name="session"),
path("session/", views.SessionListView.as_view(), name="api-session-list"),
]
+24
View File
@@ -0,0 +1,24 @@
from django.shortcuts import render
from rest_framework.views import APIView
from rest_framework.response import Response
from playlist.models import Session, Profile, Track, Vote
from playlist.serializers import TrackSerializer, SessionSerializer
from drf_spectacular.utils import extend_schema
def index(request):
return render(request, 'voting/index.html')
def session(request, session_id):
return render(request, 'voting/session.html', {'room_name': session_id})
@extend_schema(
responses={200: SessionSerializer(many=True)},
description="Retrieve a list of all available sessions."
)
class SessionListView(APIView):
def get(self, request):
if request.user.is_superuser:
sessions = Session.objects.all()
else:
sessions = Session.objects.filter(voting_open=True)
return Response(SessionSerializer(sessions, many=True).data)
+6
View File
@@ -30,6 +30,12 @@ services:
- "3003:3000"
networks:
- app_network
valkey:
image: valkey/valkey
ports:
- "6379:6379"
networks:
- app_network
networks:
app_network:
+7 -1
View File
@@ -7,8 +7,10 @@ import { Source_Sans_3, Nunito } from "next/font/google";
import AuthStatus from "muzak/components/AuthStatus";
import NavigationBar from "muzak/components/NavigationBar";
import Quota from "muzak/components/Quota";
import SessionChecker from "muzak/components/SessionChecker";
import { AnimationContext } from "muzak/contexts/AnimationContext";
import { QuotaContext } from "muzak/contexts/QuotaContext";
import { SessionContext } from "muzak/contexts/SessionContext";
const nunito = Nunito({
subsets: ["latin"],
@@ -17,6 +19,7 @@ const nunito = Nunito({
function LayoutContent({ children }: { children: React.ReactNode }) {
return (
<>
<SessionChecker />
<AuthStatus />
{children}
{/* <NavigationBar /> */}
@@ -32,6 +35,7 @@ export default function RootLayout({
}) {
const [isAnimate, setIsAnimate] = useState(false);
const [quota, setQuota] = useState<number | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const toggleAnimation = () => {
setIsAnimate(!isAnimate);
@@ -43,7 +47,9 @@ export default function RootLayout({
<AuthProvider {...oidcConfig}>
<AnimationContext.Provider value={{ isAnimate, setIsAnimate }}>
<QuotaContext.Provider value={{ quota, setQuota }}>
<LayoutContent children={children} />
<SessionContext.Provider value={{ sessionId, setSessionId }}>
<LayoutContent children={children} />
</SessionContext.Provider>
</QuotaContext.Provider>
</AnimationContext.Provider>
</AuthProvider>
+38 -22
View File
@@ -4,6 +4,7 @@ import { Markazi_Text, Nunito } from "next/font/google";
import users from "muzak/data/users.json";
import Image from "next/image";
import Link from "next/link";
import { useSession } from "muzak/contexts/SessionContext";
const nunito = Nunito({
weight: "900",
@@ -13,29 +14,44 @@ const nunito = Nunito({
const MAX_PARTY_SIZE = 8;
export default function Lobby() {
const [partySize, setPartySize] = useState(0);
const [userList, setUserList] = useState([]);
const [userList, setUserList] = useState<any[]>([]);
const [sessionSocket, setSessionSocket] = useState<WebSocket | null>(null);
const { sessionId, setSessionId } = useSession();
function onMessage(e) {
setTimeout(() => {
console.log(e);
let data = JSON.parse(e.data);
for (let key in data) {
if (key === "joined") {
addUser(data[key]);
}
}
console.log(data);
}, 1000);
}
useEffect(() => {
populateUserList();
}, [partySize]);
if (sessionId) {
const socket = new WebSocket(
`${process.env.NEXT_PUBLIC_WS_HOST}/voting/session/${sessionId}/`,
);
socket.onmessage = onMessage;
setSessionSocket(socket);
}
}, [sessionId]);
function populateUserList() {
const mappedUsers = users.slice(0, partySize).map((user) => ({
id: user.id,
name: user.name,
avatar: user.avatar,
host: user.host,
}));
setUserList(mappedUsers);
}
function addUserToParty() {
setPartySize((prev) => Math.min(prev + 1, MAX_PARTY_SIZE));
}
function deleteUserFromParty() {
setPartySize((prev) => Math.max(prev - 1, 0));
function addUser(userName: string) {
if (userList.find((user) => user.name === userName)) {
return;
}
const newUser = {
id: userList.length + 1,
name: userName,
avatar: `https://i.pravatar.cc/150?img=${userList.length + 1}`,
host: userList.length === 0,
};
setUserList((prevUserList) => [...prevUserList, newUser]);
}
return (
@@ -51,7 +67,7 @@ export default function Lobby() {
<div className="flex min-h-[532px] min-w-[500px] flex-col items-center justify-start gap-4 rounded-md bg-blue-950/80 p-8">
<div className="flex w-full flex-row items-center justify-between px-1">
<div className="text-3xl font-bold text-yellow-500">
Players: {partySize}
Players: {userList.length}
</div>
<Link
href="/voting"
@@ -61,7 +77,7 @@ export default function Lobby() {
</Link>
</div>
{partySize === 0 ? (
{userList.length === 0 ? (
<div className="mt-31 min-w-52 text-3xl font-semibold text-white drop-shadow-sm drop-shadow-gray-900">
Waiting for players...
</div>
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useSession } from "muzak/contexts/SessionContext";
import { useState, useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { getUser } from "muzak/data/fetcher";
import { Nunito } from "next/font/google";
const nunito = Nunito({
weight: "900",
subsets: ["latin"],
});
export default function Session() {
const auth = useAuth();
const { sessionId, setSessionId } = useSession();
const user = getUser();
const [sessionSocket, setSessionSocket] = useState<WebSocket | null>(null);
function connect() {
console.log(`ik ben ${user.profile.nickname}`);
console.log(sessionSocket?.readyState);
if (sessionSocket?.readyState === WebSocket.OPEN) {
console.log("socket is open");
sessionSocket.send(
JSON.stringify({
action: "login",
userr: user.profile.nickname,
token: user.access_token,
}),
);
}
}
useEffect(() => {
if (sessionId) {
const socket = new WebSocket(
`${process.env.NEXT_PUBLIC_WS_HOST}/voting/session/${sessionId}/`,
);
setSessionSocket(socket);
}
}, [sessionId]);
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div>
<h1
className={`${nunito.className} text-outline text-[42px] tracking-tighter text-yellow-500`}
>
LOGIN
</h1>
</div>
<div className="mb-12 flex flex-col items-center justify-between gap-4 rounded-md bg-blue-950/80 p-8">
<h1 className="text-xl text-white">{user.profile.nickname}</h1>
<button
className="rounded-md bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
onClick={connect}
>
Connect
</button>
</div>
</main>
);
}
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { useAuth } from "react-oidc-context";
import { useState, useEffect, use } from "react";
import { useRouter, usePathname } from "next/navigation";
import { api, Session } from "muzak/data/fetcher";
import { getUser } from "muzak/data/fetcher";
import { useSession } from "muzak/contexts/SessionContext";
export default function SessionChecker() {
const auth = useAuth();
const router = useRouter();
const [isSession, setIsSession] = useState(false);
const [link, setLink] = useState("/lobby");
const { sessionId, setSessionId } = useSession();
async function getSessions() {
if (!auth.isLoading && !auth.error && auth.isAuthenticated) {
const { data: sessions } = await api?.sessions?.list({});
console.log(sessions);
if (sessions?.length > 0) {
setIsSession(true);
const user = getUser();
setSessionId(sessions[0].session_id);
if (sessions[0].host === user?.profile?.email) {
setLink("/lobby");
} else {
setLink("/session");
}
} else {
setIsSession(false);
}
}
}
useEffect(() => {
getSessions();
}, [auth]);
if (isSession) {
return (
<Link
href={link}
className="bold text-l absolute top-2 left-2 rounded-[50%] bg-red-500 p-3 font-bold text-white"
>
Session available!
</Link>
);
}
return <> </>;
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { createContext, useContext } from "react";
interface SessionContextType {
sessionId: string;
setSessionId: React.Dispatch<React.SetStateAction<string>>;
}
const SessionContext = createContext<SessionContextType>(
{} as SessionContextType,
);
export const useSession = () => useContext(SessionContext);
export { SessionContext };
+4
View File
@@ -83,11 +83,15 @@ export const api = {
profiles: {
me: fetcher.path("/api/profile/me").method("get").create(),
},
sessions: {
list: fetcher.path("/voting/session/").method("get").create(),
},
};
export type User = components["schemas"]["User"];
export type Track = components["schemas"]["Track"];
export type Album = components["schemas"]["Album"];
export type Profile = components["schemas"]["Profile"];
export type Session = components["schemas"]["Session"];
export default api;