commit i guess
This commit is contained in:
parent
227a3d6186
commit
cacfb2a5e8
@ -105,7 +105,7 @@ async def refresh_token(
|
|||||||
"""
|
"""
|
||||||
Handles access token refresh.
|
Handles access token refresh.
|
||||||
- Verifies the provided refresh token.
|
- Verifies the provided refresh token.
|
||||||
- If valid, generates and returns a new JWT access token and the same refresh token.
|
- If valid, generates and returns a new JWT access token and a new refresh token.
|
||||||
"""
|
"""
|
||||||
logger.info("Access token refresh attempt")
|
logger.info("Access token refresh attempt")
|
||||||
payload = verify_refresh_token(refresh_token_str)
|
payload = verify_refresh_token(refresh_token_str)
|
||||||
@ -127,9 +127,10 @@ async def refresh_token(
|
|||||||
)
|
)
|
||||||
|
|
||||||
new_access_token = create_access_token(subject=user_email)
|
new_access_token = create_access_token(subject=user_email)
|
||||||
logger.info(f"Access token refreshed for user: {user_email}")
|
new_refresh_token = create_refresh_token(subject=user_email)
|
||||||
|
logger.info(f"Access token refreshed and new refresh token issued for user: {user_email}")
|
||||||
return Token(
|
return Token(
|
||||||
access_token=new_access_token,
|
access_token=new_access_token,
|
||||||
refresh_token=refresh_token_str,
|
refresh_token=new_refresh_token,
|
||||||
token_type=settings.TOKEN_TYPE
|
token_type=settings.TOKEN_TYPE
|
||||||
)
|
)
|
@ -11,8 +11,10 @@ from app.models import User as UserModel, UserRoleEnum # Import model and enum
|
|||||||
from app.schemas.group import GroupCreate, GroupPublic
|
from app.schemas.group import GroupCreate, GroupPublic
|
||||||
from app.schemas.invite import InviteCodePublic
|
from app.schemas.invite import InviteCodePublic
|
||||||
from app.schemas.message import Message # For simple responses
|
from app.schemas.message import Message # For simple responses
|
||||||
|
from app.schemas.list import ListPublic
|
||||||
from app.crud import group as crud_group
|
from app.crud import group as crud_group
|
||||||
from app.crud import invite as crud_invite
|
from app.crud import invite as crud_invite
|
||||||
|
from app.crud import list as crud_list
|
||||||
from app.core.exceptions import (
|
from app.core.exceptions import (
|
||||||
GroupNotFoundError,
|
GroupNotFoundError,
|
||||||
GroupPermissionError,
|
GroupPermissionError,
|
||||||
@ -198,3 +200,29 @@ async def remove_group_member(
|
|||||||
|
|
||||||
logger.info(f"Owner {current_user.email} successfully removed user {user_id_to_remove} from group {group_id}")
|
logger.info(f"Owner {current_user.email} successfully removed user {user_id_to_remove} from group {group_id}")
|
||||||
return Message(detail="Successfully removed member from the group")
|
return Message(detail="Successfully removed member from the group")
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{group_id}/lists",
|
||||||
|
response_model=List[ListPublic],
|
||||||
|
summary="Get Group Lists",
|
||||||
|
tags=["Groups", "Lists"]
|
||||||
|
)
|
||||||
|
async def read_group_lists(
|
||||||
|
group_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: UserModel = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Retrieves all lists belonging to a specific group, if the user is a member."""
|
||||||
|
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")
|
||||||
|
|
||||||
|
# Check if user is a member first
|
||||||
|
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
|
||||||
|
if not is_member:
|
||||||
|
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
|
||||||
|
raise GroupMembershipError(group_id, "view group lists")
|
||||||
|
|
||||||
|
# Get all lists for the user and filter by group_id
|
||||||
|
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id)
|
||||||
|
group_lists = [list for list in lists if list.group_id == group_id]
|
||||||
|
|
||||||
|
return group_lists
|
@ -23,6 +23,22 @@ from app.core.exceptions import (
|
|||||||
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
|
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
|
||||||
"""Creates a new list record."""
|
"""Creates a new list record."""
|
||||||
try:
|
try:
|
||||||
|
# Check if we're already in a transaction
|
||||||
|
if db.in_transaction():
|
||||||
|
# If we're already in a transaction, just create the list
|
||||||
|
db_list = ListModel(
|
||||||
|
name=list_in.name,
|
||||||
|
description=list_in.description,
|
||||||
|
group_id=list_in.group_id,
|
||||||
|
created_by_id=creator_id,
|
||||||
|
is_complete=False
|
||||||
|
)
|
||||||
|
db.add(db_list)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(db_list)
|
||||||
|
return db_list
|
||||||
|
else:
|
||||||
|
# If no transaction is active, start one
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
db_list = ListModel(
|
db_list = ListModel(
|
||||||
name=list_in.name,
|
name=list_in.name,
|
||||||
|
@ -38,8 +38,8 @@ async def get_db() -> AsyncSession: # type: ignore
|
|||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
# Optionally commit if your endpoints modify data directly
|
# Commit the transaction if no errors occurred
|
||||||
# await session.commit() # Usually commit happens within endpoint logic
|
await session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise
|
raise
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# app/schemas/group.py
|
# app/schemas/group.py
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, computed_field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
@ -15,7 +15,25 @@ class GroupPublic(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
created_by_id: int
|
created_by_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
members: Optional[List[UserPublic]] = None # Include members only in detailed view
|
member_associations: Optional[List["UserGroupPublic"]] = None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def members(self) -> Optional[List[UserPublic]]:
|
||||||
|
if not self.member_associations:
|
||||||
|
return None
|
||||||
|
return [assoc.user for assoc in self.member_associations if assoc.user]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
# Properties for UserGroup association
|
||||||
|
class UserGroupPublic(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
group_id: int
|
||||||
|
role: str
|
||||||
|
joined_at: datetime
|
||||||
|
user: Optional[UserPublic] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
@ -4,32 +4,22 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="createListModalTitle">Create New List</h3>
|
<h3 id="createListModalTitle">Create New List</h3>
|
||||||
<button class="close-button" @click="closeModal" aria-label="Close modal">
|
<button class="close-button" @click="closeModal" aria-label="Close modal">
|
||||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-close" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="onSubmit">
|
<form @submit.prevent="onSubmit">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="listName" class="form-label">List Name</label>
|
<label for="listName" class="form-label">List Name</label>
|
||||||
<input
|
<input type="text" id="listName" v-model="listName" class="form-input" required ref="listNameInput" />
|
||||||
type="text"
|
|
||||||
id="listName"
|
|
||||||
v-model="listName"
|
|
||||||
class="form-input"
|
|
||||||
required
|
|
||||||
ref="listNameInput"
|
|
||||||
/>
|
|
||||||
<p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p>
|
<p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description" class="form-label">Description</label>
|
<label for="description" class="form-label">Description</label>
|
||||||
<textarea
|
<textarea id="description" v-model="description" class="form-input" rows="3"></textarea>
|
||||||
id="description"
|
|
||||||
v-model="description"
|
|
||||||
class="form-input"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" v-if="groups && groups.length > 0">
|
<div class="form-group" v-if="groups && groups.length > 0">
|
||||||
@ -45,7 +35,7 @@
|
|||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button>
|
<button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary ml-2" :disabled="loading">
|
<button type="submit" class="btn btn-primary ml-2" :disabled="loading">
|
||||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -144,7 +134,9 @@ const onSubmit = async () => {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ml-2 {
|
.ml-2 {
|
||||||
margin-left: 0.5rem; /* from Valerie UI utilities */
|
margin-left: 0.5rem;
|
||||||
|
/* from Valerie UI utilities */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@ -4,13 +4,13 @@
|
|||||||
<div
|
<div
|
||||||
v-for="notification in store.notifications"
|
v-for="notification in store.notifications"
|
||||||
:key="notification.id"
|
:key="notification.id"
|
||||||
:class="['notification-item', `notification-${notification.type}`]"
|
:class="['alert', `alert-${notification.type}`]"
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
>
|
>
|
||||||
<div class="notification-content">
|
<div class="alert-content">
|
||||||
<span class="notification-icon" v-if="getIcon(notification.type)">
|
<span class="icon" v-if="getIcon(notification.type)">
|
||||||
<!-- Basic SVG Icons - replace with your preferred icon set or library -->
|
<!-- Basic SVG Icons - replace with your preferred icon set or library -->
|
||||||
<svg v-if="notification.type === 'success'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
<svg v-if="notification.type === 'success'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||||
<svg v-if="notification.type === 'error'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
<svg v-if="notification.type === 'error'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||||
@ -21,10 +21,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="store.removeNotification(notification.id)"
|
@click="store.removeNotification(notification.id)"
|
||||||
class="notification-close-button"
|
class="alert-close-btn"
|
||||||
aria-label="Close notification"
|
aria-label="Close notification"
|
||||||
>
|
>
|
||||||
× <!-- Simple 'x' close button -->
|
<span class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
@ -37,105 +37,107 @@ import { useNotificationStore, type Notification } from '@/stores/notifications'
|
|||||||
const store = useNotificationStore();
|
const store = useNotificationStore();
|
||||||
|
|
||||||
const getIcon = (type: Notification['type']) => {
|
const getIcon = (type: Notification['type']) => {
|
||||||
// You can extend this or use a more sophisticated icon system
|
// This function now primarily determines if an icon should be shown,
|
||||||
const icons = {
|
// as valerie-ui might handle specific icons via CSS or a global icon system.
|
||||||
success: 'check_circle', // Material icon names or SVG paths
|
// For this example, we're still providing SVGs directly.
|
||||||
error: 'error',
|
// valerie-ui .icon class will style the SVG size/alignment.
|
||||||
warning: 'warning',
|
const iconMap = {
|
||||||
info: 'info',
|
success: true,
|
||||||
|
error: true,
|
||||||
|
warning: true,
|
||||||
|
info: true,
|
||||||
};
|
};
|
||||||
return icons[type]; // For the SVG example, we just check the type
|
return iconMap[type];
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
// Overriding valerie-ui defaults or adding custom positioning
|
||||||
.notification-container {
|
.notification-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
z-index: 9999;
|
z-index: 9999; // Ensure it's above other valerie-ui components if necessary
|
||||||
width: 320px; // Or max-width
|
width: 350px; // Adjusted width to better fit valerie-ui alert style
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px; // Spacing between notifications
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item {
|
// Use valerie-ui's .alert class for base styling.
|
||||||
background-color: #fff;
|
// Specific type styles (alert-success, alert-error, etc.) are handled by valerie-ui.scss
|
||||||
color: #333;
|
// We can add overrides here if needed.
|
||||||
padding: 12px 16px;
|
.alert {
|
||||||
border-radius: 6px;
|
// valerie-ui .alert already has padding, border, shadow, etc.
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
// We might want to adjust margin if the gap from .notification-container isn't enough
|
||||||
display: flex;
|
// or if we want to override something specific from valerie-ui's .alert
|
||||||
|
// For example, if valerie-ui doesn't set margin-bottom on .alert:
|
||||||
|
// margin-bottom: 0; // Reset if valerie-ui adds margin, rely on gap.
|
||||||
|
|
||||||
|
// Override icon color if valerie-ui doesn't color them by alert type,
|
||||||
|
// or if our SVGs need specific coloring not handled by `fill="currentColor"` and parent color.
|
||||||
|
// valerie-ui .alert-<type> should handle border-left-color.
|
||||||
|
// valerie-ui .icon class is generic, we might need to scope it.
|
||||||
|
.alert-content > .icon {
|
||||||
|
// valerie-ui uses .icon class. Check valerie-ui.scss for its styling.
|
||||||
|
// Assuming valerie-ui's .icon class handles sizing and alignment.
|
||||||
|
// We need to ensure our SVG icons get the correct color based on notification type.
|
||||||
|
// valerie-ui .alert-<type> typically sets text color, which currentColor should pick up.
|
||||||
|
// If not, add specific color rules:
|
||||||
|
// Example:
|
||||||
|
// &.alert-success .icon { color: var(--success); } // If --success is defined in valerie or globally
|
||||||
|
// &.alert-error .icon { color: var(--danger); }
|
||||||
|
// &.alert-warning .icon { color: var(--warning); }
|
||||||
|
// &.alert-info .icon { color: var(--secondary-accent); } // Match valerie-ui's alert-info
|
||||||
|
|
||||||
|
// The SVGs provided use fill="currentColor", so they should inherit from the parent.
|
||||||
|
// The .alert-<type> classes in valerie-ui.scss set border-left-color but not necessarily text or icon color.
|
||||||
|
// Let's ensure icons match the left border color for consistency, if not already handled.
|
||||||
|
}
|
||||||
|
|
||||||
|
// If valerie-ui .alert-content needs adjustment
|
||||||
|
.alert-content {
|
||||||
|
// display: flex; align-items: center; flex-grow: 1; is usually in valerie's .alert-content
|
||||||
|
.notification-message {
|
||||||
|
font-size: 0.95rem; // Match valerie-ui's typical text size or adjust
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-left: 0.5em; // Space between icon and message if icon exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style for the close button based on valerie-ui's .alert-close-btn
|
||||||
|
.alert-close-btn {
|
||||||
|
// valerie-ui's .alert-close-btn should handle most styling.
|
||||||
|
// We might need to adjust padding or alignment if it doesn't look right
|
||||||
|
// with our custom notification container.
|
||||||
|
// The provided SVG for close button is wrapped in a span.icon, let's style that.
|
||||||
|
.icon {
|
||||||
|
display: inline-flex; // Ensure icon is aligned
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
border-left-width: 5px;
|
// valerie-ui's .alert-close-btn might already style the icon color on hover.
|
||||||
border-left-style: solid;
|
|
||||||
overflow: hidden; // For smooth animation
|
|
||||||
|
|
||||||
&.notification-success {
|
|
||||||
border-left-color: var(--positive-color, #4caf50);
|
|
||||||
.notification-icon { color: var(--positive-color, #4caf50); }
|
|
||||||
}
|
}
|
||||||
&.notification-error {
|
|
||||||
border-left-color: var(--negative-color, #f44336);
|
|
||||||
.notification-icon { color: var(--negative-color, #f44336); }
|
|
||||||
}
|
|
||||||
&.notification-warning {
|
|
||||||
border-left-color: var(--warning-color, #ff9800);
|
|
||||||
.notification-icon { color: var(--warning-color, #ff9800); }
|
|
||||||
}
|
|
||||||
&.notification-info {
|
|
||||||
border-left-color: var(--info-color, #2196f3);
|
|
||||||
.notification-icon { color: var(--info-color, #2196f3); }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-icon {
|
// Transitions for the list - these should still work fine.
|
||||||
margin-right: 10px;
|
// Ensure the transitions target the .alert class now.
|
||||||
display: flex; // To align SVG properly
|
|
||||||
align-items: center;
|
|
||||||
// SVGs inside will inherit the color from the parent rule
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-message {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-close-button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0 0 10px; // Add some space to the left
|
|
||||||
margin-left: auto; // Pushes it to the right within flex
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transitions for the list
|
|
||||||
.notification-list-enter-active,
|
.notification-list-enter-active,
|
||||||
.notification-list-leave-active {
|
.notification-list-leave-active {
|
||||||
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55); // A bit bouncy
|
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55);
|
||||||
}
|
}
|
||||||
.notification-list-enter-from,
|
.notification-list-enter-from,
|
||||||
.notification-list-leave-to {
|
.notification-list-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(100%); // Slide in from right
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
.notification-list-leave-active {
|
.notification-list-leave-active {
|
||||||
position: absolute; // Important for smooth leave transitions in a list
|
position: absolute;
|
||||||
width: calc(100% - 32px); // Adjust based on padding if item width is not fixed
|
// Ensure width calculation is correct if items have variable width or valerie-ui adds padding/margin
|
||||||
|
// This might need to be calc(100%) if .alert is full width of its slot in notification-container.
|
||||||
|
// Or if valerie-ui .alert has its own padding that affects its outer width.
|
||||||
|
// For now, assuming .alert takes the width of the slot provided by transition-group.
|
||||||
|
width: 100%; // Re-evaluate if needed after valerie styling.
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-layout">
|
<div class="main-layout">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="toolbar-title">Mooo</div>
|
<div class="toolbar-title">mitlist</div>
|
||||||
<div class="user-menu" v-if="authStore.isAuthenticated">
|
<div class="user-menu" v-if="authStore.isAuthenticated">
|
||||||
<button @click="toggleUserMenu" class="user-menu-button">
|
<button @click="toggleUserMenu" class="user-menu-button">
|
||||||
<!-- Placeholder for user icon -->
|
<!-- Placeholder for user icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
||||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
<a href="#" @click.prevent="handleLogout">Logout</a>
|
||||||
@ -93,8 +93,10 @@ const handleLogout = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-title {
|
.toolbar-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.1ch;
|
||||||
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu {
|
.user-menu {
|
||||||
@ -104,7 +106,7 @@ const handleLogout = async () => {
|
|||||||
.user-menu-button {
|
.user-menu-button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: var(--primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@ -120,7 +122,8 @@ const handleLogout = async () => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: calc(100% + 5px);
|
top: calc(100% + 5px);
|
||||||
background-color: white;
|
color: var(--primary);
|
||||||
|
background-color: #f3f3f3;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
@ -10,6 +10,7 @@ import './assets/main.scss';
|
|||||||
|
|
||||||
// API client (from your axios boot file)
|
// API client (from your axios boot file)
|
||||||
import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to services/api
|
import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to services/api
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
// Vue I18n setup (from your i18n boot file)
|
// Vue I18n setup (from your i18n boot file)
|
||||||
// export type MessageLanguages = keyof typeof messages;
|
// export type MessageLanguages = keyof typeof messages;
|
||||||
@ -29,8 +30,18 @@ import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to
|
|||||||
// });
|
// });
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
const pinia = createPinia();
|
||||||
|
app.use(pinia);
|
||||||
|
|
||||||
|
// Initialize auth state before mounting the app
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
if (authStore.accessToken) {
|
||||||
|
authStore.fetchCurrentUser().catch(error => {
|
||||||
|
console.error('Failed to initialize current user state:', error);
|
||||||
|
// The fetchCurrentUser action handles token clearing on failure.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.use(createPinia());
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
// app.use(i18n);
|
// app.use(i18n);
|
||||||
|
|
||||||
|
@ -1,44 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container page-padding">
|
<main class="container page-padding">
|
||||||
<div v-if="loading" class="text-center">
|
<div v-if="loading" class="text-center">
|
||||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||||
<p>Loading group details...</p>
|
<p>Loading group details...</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="error" class="alert alert-error" role="alert">
|
<div v-else-if="error" class="alert alert-error" role="alert">
|
||||||
<div class="alert-content">
|
<div class="alert-content">
|
||||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-alert-triangle" />
|
||||||
|
</svg>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="group">
|
<div v-else-if="group">
|
||||||
<h1 class="mb-3">Group: {{ group.name }}</h1>
|
<h1 class="mb-3">Group: {{ group.name }}</h1>
|
||||||
|
|
||||||
|
<!-- Group Members Section -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Group Members</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="group.members && group.members.length > 0" class="members-list">
|
||||||
|
<div v-for="member in group.members" :key="member.id" class="member-item">
|
||||||
|
<div class="member-info">
|
||||||
|
<span class="member-name">{{ member.email }}</span>
|
||||||
|
<span class="member-role" :class="member.role?.toLowerCase()">{{ member.role || 'Member' }}</span>
|
||||||
|
</div>
|
||||||
|
<button v-if="canRemoveMember(member)" class="btn btn-danger btn-sm" @click="removeMember(member.id)"
|
||||||
|
:disabled="removingMember === member.id">
|
||||||
|
<span v-if="removingMember === member.id" class="spinner-dots-sm"
|
||||||
|
role="status"><span /><span /><span /></span>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted">
|
||||||
|
No members found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Placeholder for lists related to this group -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<ListsPage :group-id="groupId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Invite Members Section -->
|
<!-- Invite Members Section -->
|
||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Invite Members</h3>
|
<h3>Invite Members</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<button
|
<button class="btn btn-secondary" @click="generateInviteCode" :disabled="generatingInvite">
|
||||||
class="btn btn-secondary"
|
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||||
@click="generateInviteCode"
|
|
||||||
:disabled="generatingInvite"
|
|
||||||
>
|
|
||||||
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
|
||||||
Generate Invite Code
|
Generate Invite Code
|
||||||
</button>
|
</button>
|
||||||
<div v-if="inviteCode" class="form-group mt-2">
|
<div v-if="inviteCode" class="form-group mt-2">
|
||||||
<label for="inviteCodeInput" class="form-label">Invite Code:</label>
|
<label for="inviteCodeInput" class="form-label">Invite Code:</label>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input
|
<input id="inviteCodeInput" type="text" :value="inviteCode" class="form-input flex-grow" readonly />
|
||||||
id="inviteCodeInput"
|
<button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler"
|
||||||
type="text"
|
aria-label="Copy invite code">
|
||||||
:value="inviteCode"
|
<svg class="icon">
|
||||||
class="form-input flex-grow"
|
<use xlink:href="#icon-clipboard"></use>
|
||||||
readonly
|
</svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
|
||||||
/>
|
|
||||||
<button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler" aria-label="Copy invite code">
|
|
||||||
<svg class="icon"><use xlink:href="#icon-clipboard"></use></svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
|
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
|
||||||
@ -46,13 +72,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Placeholder for lists related to this group -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<h2>Lists in this Group</h2>
|
|
||||||
<ListsPage :group-id="groupId" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
<div v-else class="alert alert-info" role="status">
|
<div v-else class="alert alert-info" role="status">
|
||||||
<div class="alert-content">Group not found or an error occurred.</div>
|
<div class="alert-content">Group not found or an error occurred.</div>
|
||||||
</div>
|
</div>
|
||||||
@ -68,13 +89,19 @@ import ListsPage from './ListsPage.vue'; // Import ListsPage
|
|||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
id: string | number; // API might return number
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
// other properties if needed
|
members?: GroupMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMember {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id: string; // From router param, always string
|
id: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// const route = useRoute();
|
// const route = useRoute();
|
||||||
@ -87,11 +114,14 @@ const error = ref<string | null>(null);
|
|||||||
const inviteCode = ref<string | null>(null);
|
const inviteCode = ref<string | null>(null);
|
||||||
const generatingInvite = ref(false);
|
const generatingInvite = ref(false);
|
||||||
const copySuccess = ref(false);
|
const copySuccess = ref(false);
|
||||||
|
const removingMember = ref<number | null>(null);
|
||||||
|
|
||||||
// groupId is directly from props.id now, which comes from the route path param
|
// groupId is directly from props.id now, which comes from the route path param
|
||||||
const groupId = computed(() => props.id);
|
const groupId = computed(() => props.id);
|
||||||
|
|
||||||
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({ source: inviteCode });
|
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
|
||||||
|
source: computed(() => inviteCode.value || '')
|
||||||
|
});
|
||||||
|
|
||||||
const fetchGroupDetails = async () => {
|
const fetchGroupDetails = async () => {
|
||||||
if (!groupId.value) return;
|
if (!groupId.value) return;
|
||||||
@ -146,6 +176,33 @@ const copyInviteCodeHandler = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canRemoveMember = (member: GroupMember): boolean => {
|
||||||
|
// Only allow removing members if the current user is the owner
|
||||||
|
// and the member is not the owner themselves
|
||||||
|
return group.value?.members?.some(m => m.role === 'owner' && m.id === member.id) === false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMember = async (memberId: number) => {
|
||||||
|
if (!groupId.value) return;
|
||||||
|
|
||||||
|
removingMember.value = memberId;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId.value), String(memberId)));
|
||||||
|
// Refresh group details to update the members list
|
||||||
|
await fetchGroupDetails();
|
||||||
|
notificationStore.addNotification({
|
||||||
|
message: 'Member removed successfully',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to remove member';
|
||||||
|
console.error('Error removing member:', err);
|
||||||
|
notificationStore.addNotification({ message, type: 'error' });
|
||||||
|
} finally {
|
||||||
|
removingMember.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchGroupDetails();
|
fetchGroupDetails();
|
||||||
});
|
});
|
||||||
@ -155,17 +212,89 @@ onMounted(() => {
|
|||||||
.page-padding {
|
.page-padding {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.mt-1 { margin-top: 0.5rem; }
|
|
||||||
.mt-2 { margin-top: 1rem; }
|
.mt-1 {
|
||||||
.mt-3 { margin-top: 1.5rem; }
|
margin-top: 0.5rem;
|
||||||
.mt-4 { margin-top: 2rem; }
|
}
|
||||||
.mb-3 { margin-bottom: 1.5rem; }
|
|
||||||
.ml-1 { margin-left: 0.25rem; } /* Adjusted from Valerie UI for tighter fit */
|
.mt-2 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-3 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-1 {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjusted from Valerie UI for tighter fit */
|
||||||
|
|
||||||
.form-success-text {
|
.form-success-text {
|
||||||
color: var(--success); /* Or a darker green for text */
|
color: var(--success);
|
||||||
|
/* Or a darker green for text */
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.flex-grow { flex-grow: 1; }
|
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Members list styles */
|
||||||
|
.members-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-role {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background-color: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-role.owner {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -3,16 +3,51 @@
|
|||||||
<div class="flex justify-between items-center mb-3">
|
<div class="flex justify-between items-center mb-3">
|
||||||
<h1>Your Groups</h1>
|
<h1>Your Groups</h1>
|
||||||
<button class="btn btn-primary" @click="openCreateGroupDialog">
|
<button class="btn btn-primary" @click="openCreateGroupDialog">
|
||||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-plus" />
|
||||||
|
</svg>
|
||||||
Create Group
|
Create Group
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Join Group Section (using details/summary for expansion) -->
|
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center">
|
||||||
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||||
|
<p>Loading groups...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="fetchError" class="alert alert-error" role="alert">
|
||||||
|
<div class="alert-content">
|
||||||
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-alert-triangle" />
|
||||||
|
</svg>
|
||||||
|
{{ fetchError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="groups.length" class="item-list">
|
||||||
|
<li v-for="group in groups" :key="group.id" class="list-item interactive-list-item" @click="selectGroup(group)"
|
||||||
|
@keydown.enter="selectGroup(group)" tabindex="0">
|
||||||
|
<div class="list-item-content">
|
||||||
|
<span class="item-text">{{ group.name }}</span>
|
||||||
|
<!-- Could add more details here if needed -->
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="card empty-state-card">
|
||||||
|
<svg class="icon icon-lg" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-clipboard" />
|
||||||
|
</svg>
|
||||||
|
<h3>No Groups Yet!</h3>
|
||||||
|
<p>You are not a member of any groups yet. Create one or join using an invite code.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<details class="card mb-3">
|
<details class="card mb-3">
|
||||||
<summary class="card-header flex items-center cursor-pointer" style="display: flex; justify-content: space-between;">
|
<summary class="card-header flex items-center cursor-pointer"
|
||||||
|
style="display: flex; justify-content: space-between;">
|
||||||
<h3>
|
<h3>
|
||||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-user" /></svg> <!-- Placeholder icon -->
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-user" />
|
||||||
|
</svg> <!-- Placeholder icon -->
|
||||||
Join a Group with Invite Code
|
Join a Group with Invite Code
|
||||||
</h3>
|
</h3>
|
||||||
<span class="expand-icon" aria-hidden="true">▼</span> <!-- Basic expand indicator -->
|
<span class="expand-icon" aria-hidden="true">▼</span> <!-- Basic expand indicator -->
|
||||||
@ -21,18 +56,11 @@
|
|||||||
<form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
|
<form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
|
||||||
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
||||||
<label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
|
<label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
|
||||||
<input
|
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
|
||||||
type="text"
|
placeholder="Enter Invite Code" required ref="joinInviteCodeInputRef" />
|
||||||
id="joinInviteCodeInput"
|
|
||||||
v-model="inviteCodeToJoin"
|
|
||||||
class="form-input"
|
|
||||||
placeholder="Enter Invite Code"
|
|
||||||
required
|
|
||||||
ref="joinInviteCodeInputRef"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
|
<button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
|
||||||
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||||
Join
|
Join
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -40,65 +68,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center">
|
|
||||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
|
||||||
<p>Loading groups...</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="fetchError" class="alert alert-error" role="alert">
|
|
||||||
<div class="alert-content">
|
|
||||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
|
||||||
{{ fetchError }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul v-else-if="groups.length" class="item-list">
|
|
||||||
<li
|
|
||||||
v-for="group in groups"
|
|
||||||
:key="group.id"
|
|
||||||
class="list-item interactive-list-item"
|
|
||||||
@click="selectGroup(group)"
|
|
||||||
@keydown.enter="selectGroup(group)"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<div class="list-item-content">
|
|
||||||
<span class="item-text">{{ group.name }}</span>
|
|
||||||
<!-- Could add more details here if needed -->
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div v-else class="card empty-state-card">
|
|
||||||
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
|
|
||||||
<h3>No Groups Yet!</h3>
|
|
||||||
<p>You are not a member of any groups yet. Create one or join using an invite code.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create Group Dialog -->
|
<!-- Create Group Dialog -->
|
||||||
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
|
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
|
||||||
<div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true" aria-labelledby="createGroupTitle">
|
<div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="createGroupTitle">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="createGroupTitle">Create New Group</h3>
|
<h3 id="createGroupTitle">Create New Group</h3>
|
||||||
<button class="close-button" @click="closeCreateGroupDialog" aria-label="Close">
|
<button class="close-button" @click="closeCreateGroupDialog" aria-label="Close">
|
||||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-close" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="handleCreateGroup">
|
<form @submit.prevent="handleCreateGroup">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="newGroupNameInput" class="form-label">Group Name</label>
|
<label for="newGroupNameInput" class="form-label">Group Name</label>
|
||||||
<input
|
<input type="text" id="newGroupNameInput" v-model="newGroupName" class="form-input" required
|
||||||
type="text"
|
ref="newGroupNameInputRef" />
|
||||||
id="newGroupNameInput"
|
|
||||||
v-model="newGroupName"
|
|
||||||
class="form-input"
|
|
||||||
required
|
|
||||||
ref="newGroupNameInputRef"
|
|
||||||
/>
|
|
||||||
<p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
|
<p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button>
|
<button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary ml-2" :disabled="creatingGroup">
|
<button type="submit" class="btn btn-primary ml-2" :disabled="creatingGroup">
|
||||||
<span v-if="creatingGroup" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
<span v-if="creatingGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -245,39 +239,63 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-padding { padding: 1rem; }
|
.page-padding {
|
||||||
.mb-3 { margin-bottom: 1.5rem; }
|
padding: 1rem;
|
||||||
.mt-1 { margin-top: 0.5rem; }
|
}
|
||||||
.ml-2 { margin-left: 0.5rem; }
|
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-1 {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.interactive-list-item {
|
.interactive-list-item {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.interactive-list-item:hover,
|
.interactive-list-item:hover,
|
||||||
.interactive-list-item:focus-visible {
|
.interactive-list-item:focus-visible {
|
||||||
background-color: rgba(0,0,0,0.03);
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
outline: var(--focus-outline);
|
outline: var(--focus-outline);
|
||||||
outline-offset: -3px; /* Adjust to be inside the border */
|
outline-offset: -3px;
|
||||||
|
/* Adjust to be inside the border */
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-error-text {
|
.form-error-text {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.flex-grow { flex-grow: 1; }
|
|
||||||
|
|
||||||
details > summary {
|
.flex-grow {
|
||||||
list-style: none; /* Hide default marker */
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
details > summary::-webkit-details-marker {
|
|
||||||
display: none; /* Hide default marker for Chrome */
|
details>summary {
|
||||||
|
list-style: none;
|
||||||
|
/* Hide default marker */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details>summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
/* Hide default marker for Chrome */
|
||||||
|
}
|
||||||
|
|
||||||
.expand-icon {
|
.expand-icon {
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
details[open] .expand-icon {
|
details[open] .expand-icon {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
.cursor-pointer { cursor: pointer; }
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -3,48 +3,52 @@
|
|||||||
<h1 class="mb-3">{{ pageTitle }}</h1>
|
<h1 class="mb-3">{{ pageTitle }}</h1>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center">
|
<div v-if="loading" class="text-center">
|
||||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||||
<p>Loading lists...</p>
|
<p>Loading lists...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||||
<div class="alert-content">
|
<div class="alert-content">
|
||||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-alert-triangle" />
|
||||||
|
</svg>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button>
|
<button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredLists.length === 0" class="card empty-state-card">
|
<div v-else-if="lists.length === 0" class="card empty-state-card">
|
||||||
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
|
<svg class="icon icon-lg" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-clipboard" />
|
||||||
|
</svg>
|
||||||
<h3>{{ noListsMessage }}</h3>
|
<h3>{{ noListsMessage }}</h3>
|
||||||
<p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
|
<p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
|
||||||
<p v-else>This group doesn't have any lists yet.</p>
|
<p v-else>This group doesn't have any lists yet.</p>
|
||||||
<button class="btn btn-primary mt-2" @click="showCreateModal = true">
|
<button class="btn btn-primary mt-2" @click="showCreateModal = true">
|
||||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use xlink:href="#icon-plus" />
|
||||||
|
</svg>
|
||||||
Create New List
|
Create New List
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-else class="item-list">
|
<ul v-else class="item-list">
|
||||||
<li
|
<li v-for="list in lists" :key="list.id" class="list-item interactive-list-item" tabindex="0"
|
||||||
v-for="list in filteredLists"
|
@click="navigateToList(list.id)" @keydown.enter="navigateToList(list.id)">
|
||||||
:key="list.id"
|
|
||||||
class="list-item interactive-list-item"
|
|
||||||
tabindex="0"
|
|
||||||
@click="navigateToList(list.id)"
|
|
||||||
@keydown.enter="navigateToList(list.id)"
|
|
||||||
>
|
|
||||||
<div class="list-item-content">
|
<div class="list-item-content">
|
||||||
<div class="list-item-main" style="flex-direction: column; align-items: flex-start;">
|
<div class="list-item-main" style="flex-direction: column; align-items: flex-start;">
|
||||||
<span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span>
|
<span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span>
|
||||||
<small class="item-caption">{{ list.description || 'No description' }}</small>
|
<small class="item-caption">{{ list.description || 'No description' }}</small>
|
||||||
<small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption">
|
<small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption">
|
||||||
<svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> Personal List
|
<svg class="icon icon-sm">
|
||||||
|
<use xlink:href="#icon-user" />
|
||||||
|
</svg> Personal List
|
||||||
</small>
|
</small>
|
||||||
<small v-if="list.group_id && !props.groupId" class="item-caption icon-caption">
|
<small v-if="list.group_id && !props.groupId" class="item-caption icon-caption">
|
||||||
<svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> <!-- Placeholder, group icon not in Valerie -->
|
<svg class="icon icon-sm">
|
||||||
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}`}})
|
<use xlink:href="#icon-user" />
|
||||||
|
</svg> <!-- Placeholder, group icon not in Valerie -->
|
||||||
|
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}` }})
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
|
<div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
|
||||||
@ -60,24 +64,18 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="page-sticky-bottom-right">
|
<div class="page-sticky-bottom-right">
|
||||||
<button
|
<button class="btn btn-primary btn-icon-only" style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
|
||||||
class="btn btn-primary btn-icon-only"
|
@click="showCreateModal = true" :aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
|
||||||
style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
|
data-tooltip="Create New List">
|
||||||
@click="showCreateModal = true"
|
<svg class="icon icon-lg" style="margin-right:0;">
|
||||||
:aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
|
<use xlink:href="#icon-plus" />
|
||||||
data-tooltip="Create New List"
|
</svg>
|
||||||
>
|
|
||||||
<svg class="icon icon-lg" style="margin-right:0;"><use xlink:href="#icon-plus" /></svg>
|
|
||||||
</button>
|
</button>
|
||||||
<!-- Basic Tooltip (requires JS from Valerie UI example to function on hover/focus) -->
|
<!-- Basic Tooltip (requires JS from Valerie UI example to function on hover/focus) -->
|
||||||
<!-- <span class="tooltip-text" role="tooltip">{{ currentGroupId ? 'Create Group List' : 'Create List' }}</span> -->
|
<!-- <span class="tooltip-text" role="tooltip">{{ currentGroupId ? 'Create Group List' : 'Create List' }}</span> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateListModal
|
<CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
|
||||||
v-model="showCreateModal"
|
|
||||||
:groups="availableGroupsForModal"
|
|
||||||
@created="onListCreated"
|
|
||||||
/>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -158,7 +156,7 @@ const pageTitle = computed(() => {
|
|||||||
? `Lists for ${currentViewedGroup.value.name}`
|
? `Lists for ${currentViewedGroup.value.name}`
|
||||||
: `Lists for Group ${currentGroupId.value}`;
|
: `Lists for Group ${currentGroupId.value}`;
|
||||||
}
|
}
|
||||||
return 'All My Lists';
|
return 'My Lists';
|
||||||
});
|
});
|
||||||
|
|
||||||
const noListsMessage = computed(() => {
|
const noListsMessage = computed(() => {
|
||||||
@ -239,18 +237,30 @@ watch(currentGroupId, () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-padding { padding: 1rem; }
|
.page-padding {
|
||||||
.mb-3 { margin-bottom: 1.5rem; }
|
padding: 1rem;
|
||||||
.mt-1 { margin-top: 0.5rem; }
|
}
|
||||||
.mt-2 { margin-top: 1rem; }
|
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-1 {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.interactive-list-item {
|
.interactive-list-item {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.interactive-list-item:hover,
|
.interactive-list-item:hover,
|
||||||
.interactive-list-item:focus-visible {
|
.interactive-list-item:focus-visible {
|
||||||
background-color: rgba(0,0,0,0.03);
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
outline: var(--focus-outline);
|
outline: var(--focus-outline);
|
||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
@ -261,18 +271,23 @@ watch(currentGroupId, () => {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-caption .icon {
|
.icon-caption .icon {
|
||||||
vertical-align: -0.1em; /* Align icon better with text */
|
vertical-align: -0.1em;
|
||||||
|
/* Align icon better with text */
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-sticky-bottom-right {
|
.page-sticky-bottom-right {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1.5rem;
|
bottom: 5rem;
|
||||||
right: 1.5rem;
|
right: 1.5rem;
|
||||||
z-index: 999; /* Below modals */
|
z-index: 999;
|
||||||
|
/* Below modals */
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-sticky-bottom-right .btn {
|
.page-sticky-bottom-right .btn {
|
||||||
box-shadow: var(--shadow-lg); /* Make it pop more */
|
box-shadow: var(--shadow-lg);
|
||||||
|
/* Make it pop more */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure list item content uses full width for proper layout */
|
/* Ensure list item content uses full width for proper layout */
|
||||||
@ -280,14 +295,19 @@ watch(currentGroupId, () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: flex-start; /* Align items to top if they wrap */
|
align-items: flex-start;
|
||||||
|
/* Align items to top if they wrap */
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item-main {
|
.list-item-main {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
margin-right: 1rem; /* Space before details */
|
margin-right: 1rem;
|
||||||
|
/* Space before details */
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item-details {
|
.list-item-details {
|
||||||
flex-shrink: 0; /* Prevent badges from shrinking */
|
flex-shrink: 0;
|
||||||
|
/* Prevent badges from shrinking */
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@ -1,5 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { API_BASE_URL } from '@/config/api-config'; // api-config.ts can be moved to src/config/
|
import { API_BASE_URL } from '@/config/api-config'; // api-config.ts can be moved to src/config/
|
||||||
|
import router from '@/router'; // Import the router instance
|
||||||
|
import { useAuthStore } from '@/stores/auth'; // Import the auth store
|
||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
@ -28,35 +30,36 @@ api.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
|
const authStore = useAuthStore(); // Get auth store instance
|
||||||
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
try {
|
try {
|
||||||
const refreshToken = localStorage.getItem('refreshToken');
|
const refreshTokenValue = authStore.refreshToken; // Get from store for consistency
|
||||||
if (!refreshToken) {
|
if (!refreshTokenValue) {
|
||||||
// No refresh token, logout or redirect
|
|
||||||
// This logic might be better handled in the auth store or router
|
|
||||||
console.error('No refresh token, redirecting to login');
|
console.error('No refresh token, redirecting to login');
|
||||||
// Consider emitting an event or calling an auth store action
|
authStore.clearTokens(); // Clear tokens in store and localStorage
|
||||||
// window.location.href = '/auth/login'; // Avoid direct manipulation if possible
|
await router.push('/auth/login');
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post('/api/v1/auth/refresh-token', { // Ensure this path is correct
|
// Use the store's refresh mechanism if it already handles API call and token setting
|
||||||
refresh_token: refreshToken,
|
// However, the interceptor is specifically for retrying requests, so direct call is fine here
|
||||||
|
// as long as it correctly updates tokens for the subsequent retry.
|
||||||
|
const response = await api.post('/api/v1/auth/refresh', {
|
||||||
|
refresh_token: refreshTokenValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data;
|
const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data;
|
||||||
localStorage.setItem('token', newAccessToken);
|
// The authStore.setTokens will update localStorage as well.
|
||||||
if (newRefreshToken) localStorage.setItem('refreshToken', newRefreshToken); // Backend might not always send a new one
|
authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken });
|
||||||
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
return api(originalRequest);
|
return api(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
console.error('Refresh token failed:', refreshError);
|
console.error('Refresh token failed:', refreshError);
|
||||||
localStorage.removeItem('token');
|
authStore.clearTokens(); // Clear tokens in store and localStorage
|
||||||
localStorage.removeItem('refreshToken');
|
await router.push('/auth/login');
|
||||||
// window.location.href = '/auth/login'; // Avoid direct manipulation
|
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { API_ENDPOINTS } from '@/config/api-config';
|
|||||||
import { apiClient } from '@/services/api';
|
import { apiClient } from '@/services/api';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import router from '@/router';
|
||||||
|
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@ -10,6 +11,7 @@ interface AuthState {
|
|||||||
user: {
|
user: {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
id?: string | number;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,6 +45,34 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
user.value = userData;
|
user.value = userData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCurrentUser = async () => {
|
||||||
|
if (!accessToken.value) {
|
||||||
|
clearTokens();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
|
||||||
|
setUser(response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('AuthStore: Failed to fetch current user:', error);
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken();
|
||||||
|
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
|
||||||
|
setUser(response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (refreshOrRetryError) {
|
||||||
|
console.error('AuthStore: Failed to refresh token or fetch user after refresh:', refreshOrRetryError);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearTokens();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('username', email);
|
formData.append('username', email);
|
||||||
@ -56,6 +86,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const { access_token, refresh_token } = response.data;
|
const { access_token, refresh_token } = response.data;
|
||||||
setTokens({ access_token, refresh_token });
|
setTokens({ access_token, refresh_token });
|
||||||
|
await fetchCurrentUser();
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,6 +97,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const refreshAccessToken = async () => {
|
const refreshAccessToken = async () => {
|
||||||
if (!refreshToken.value) {
|
if (!refreshToken.value) {
|
||||||
|
clearTokens();
|
||||||
|
await router.push('/auth/login');
|
||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,31 +107,32 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
refresh_token: refreshToken.value,
|
refresh_token: refreshToken.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { access_token, refresh_token } = response.data;
|
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||||
setTokens({ access_token, refresh_token });
|
setTokens({ access_token, refresh_token: newRefreshToken });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('AuthStore: Refresh token failed:', error);
|
||||||
clearTokens();
|
clearTokens();
|
||||||
|
await router.push('/auth/login');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
|
await router.push('/auth/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
user,
|
user,
|
||||||
// Getters
|
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
getUser,
|
getUser,
|
||||||
// Actions
|
|
||||||
setTokens,
|
setTokens,
|
||||||
clearTokens,
|
clearTokens,
|
||||||
setUser,
|
setUser,
|
||||||
|
fetchCurrentUser,
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
|
Loading…
Reference in New Issue
Block a user