Implement ProfilePage and profanity filter for display names (F1-006)
ProfilePage implementation: - Full profile page with avatar, editable display name, session count - LinkedAccountCard and DisplayNameEditor components - useProfile composable wrapping user store operations - Support for linking/unlinking OAuth providers - Logout and logout-all-devices functionality Profanity service with bypass detection: - Uses better-profanity library for base detection - Enhanced to catch common bypass attempts: - Number suffixes/prefixes (shit123, 69fuck) - Leet-speak substitutions (sh1t, f@ck, $hit) - Separator characters (s.h.i.t, f-u-c-k) - Integrated into PATCH /api/users/me endpoint - 17 unit tests covering all normalization strategies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3cc8d6645e
commit
cd3efcb528
@ -30,6 +30,7 @@ from app.api.deps import CurrentUser, DeckServiceDep, UserServiceDep
|
||||
from app.schemas.deck import DeckResponse, StarterDeckSelectRequest, StarterStatusResponse
|
||||
from app.schemas.user import UserResponse, UserUpdate
|
||||
from app.services.deck_service import DeckLimitExceededError, StarterAlreadySelectedError
|
||||
from app.services.profanity_service import validate_display_name
|
||||
from app.services.token_store import token_store
|
||||
from app.services.user_service import AccountLinkingError
|
||||
|
||||
@ -77,7 +78,19 @@ async def update_current_user_profile(
|
||||
|
||||
Returns:
|
||||
Updated user profile.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if display_name contains profanity.
|
||||
"""
|
||||
# Validate display name for profanity
|
||||
if update_data.display_name is not None:
|
||||
is_valid, error = validate_display_name(update_data.display_name)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
updated_user = await user_service.update(user.id, update_data)
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
|
||||
218
backend/app/services/profanity_service.py
Normal file
218
backend/app/services/profanity_service.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""Profanity filtering service for user-generated content.
|
||||
|
||||
This module provides validation for display names and other user-provided
|
||||
text to filter inappropriate language.
|
||||
|
||||
Uses the better-profanity library for detection with customizable word lists.
|
||||
Includes preprocessing to catch common bypass attempts like:
|
||||
- Numbers attached to words (shit123)
|
||||
- Leet-speak substitutions (sh1t, f@ck)
|
||||
- Special characters embedded in words (s.h.i.t)
|
||||
|
||||
Example:
|
||||
from app.services.profanity_service import validate_display_name
|
||||
|
||||
# In an API endpoint
|
||||
is_valid, error = validate_display_name("PlayerName")
|
||||
if not is_valid:
|
||||
raise HTTPException(400, error)
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from better_profanity import profanity
|
||||
|
||||
# Initialize profanity filter with default word list
|
||||
# Can be customized with profanity.add_censor_words([...])
|
||||
profanity.load_censor_words()
|
||||
|
||||
# Leet-speak character mappings for normalization
|
||||
LEET_SUBSTITUTIONS: dict[str, str] = {
|
||||
"0": "o",
|
||||
"1": "i",
|
||||
"3": "e",
|
||||
"4": "a",
|
||||
"5": "s",
|
||||
"7": "t",
|
||||
"8": "b",
|
||||
"@": "a",
|
||||
"$": "s",
|
||||
"!": "i",
|
||||
"+": "t",
|
||||
}
|
||||
|
||||
|
||||
class ProfanityValidationError(Exception):
|
||||
"""Error raised when content contains profanity."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _separate_letters_numbers(text: str) -> str:
|
||||
"""Separate letter sequences from number sequences with spaces.
|
||||
|
||||
Catches bypass attempts like "shit123" -> "shit 123".
|
||||
|
||||
Args:
|
||||
text: The text to process.
|
||||
|
||||
Returns:
|
||||
Text with spaces between letter and number sequences.
|
||||
"""
|
||||
result = re.sub(r"([a-zA-Z])(\d)", r"\1 \2", text)
|
||||
result = re.sub(r"(\d)([a-zA-Z])", r"\1 \2", result)
|
||||
return result
|
||||
|
||||
|
||||
def _apply_leet_substitutions(text: str) -> str:
|
||||
"""Convert leet-speak characters to their letter equivalents.
|
||||
|
||||
Catches bypass attempts like "sh1t", "f@ck", "$hit".
|
||||
|
||||
Args:
|
||||
text: The text to process.
|
||||
|
||||
Returns:
|
||||
Text with leet-speak characters replaced.
|
||||
"""
|
||||
result = text.lower()
|
||||
for leet, letter in LEET_SUBSTITUTIONS.items():
|
||||
result = result.replace(leet, letter)
|
||||
return result
|
||||
|
||||
|
||||
def _remove_separators(text: str) -> str:
|
||||
"""Remove separator characters used to break up words.
|
||||
|
||||
Catches bypass attempts like "s.h.i.t", "f-u-c-k".
|
||||
|
||||
Args:
|
||||
text: The text to process.
|
||||
|
||||
Returns:
|
||||
Text with separator characters removed.
|
||||
"""
|
||||
return re.sub(r"[.\-_]", "", text)
|
||||
|
||||
|
||||
def contains_profanity(text: str) -> bool:
|
||||
"""Check if text contains profanity.
|
||||
|
||||
Applies multiple normalization strategies to catch bypass attempts:
|
||||
1. Direct check on original text
|
||||
2. Separate letters from numbers (shit123 -> shit 123)
|
||||
3. Leet-speak substitution (sh1t -> shit)
|
||||
4. Separator removal (s.h.i.t -> shit)
|
||||
|
||||
Args:
|
||||
text: The text to check.
|
||||
|
||||
Returns:
|
||||
True if profanity is detected, False otherwise.
|
||||
|
||||
Example:
|
||||
if contains_profanity(username):
|
||||
reject_username()
|
||||
"""
|
||||
# Check original text first
|
||||
if profanity.contains_profanity(text):
|
||||
return True
|
||||
|
||||
# Check with letters separated from numbers (shit123 -> shit 123)
|
||||
separated = _separate_letters_numbers(text)
|
||||
if profanity.contains_profanity(separated):
|
||||
return True
|
||||
|
||||
# Check with leet-speak substitutions
|
||||
leet_normalized = _apply_leet_substitutions(text)
|
||||
if profanity.contains_profanity(leet_normalized):
|
||||
return True
|
||||
|
||||
# Check with separators removed (s.h.i.t -> shit)
|
||||
no_separators = _remove_separators(text)
|
||||
if profanity.contains_profanity(no_separators):
|
||||
return True
|
||||
|
||||
# Check combined: leet + separators removed
|
||||
combined = _apply_leet_substitutions(_remove_separators(text))
|
||||
return bool(profanity.contains_profanity(combined))
|
||||
|
||||
|
||||
def validate_display_name(name: str) -> tuple[bool, str | None]:
|
||||
"""Validate a display name for profanity.
|
||||
|
||||
Uses enhanced profanity detection that catches bypass attempts
|
||||
like leet-speak (sh1t) and number suffixes (shit123).
|
||||
|
||||
Args:
|
||||
name: The display name to validate.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message).
|
||||
If valid, returns (True, None).
|
||||
If invalid, returns (False, "error message").
|
||||
|
||||
Example:
|
||||
is_valid, error = validate_display_name("BadWord123")
|
||||
if not is_valid:
|
||||
raise HTTPException(400, error)
|
||||
"""
|
||||
if contains_profanity(name):
|
||||
return False, "Display name contains inappropriate language"
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_text(text: str, field_name: str = "text") -> tuple[bool, str | None]:
|
||||
"""Validate arbitrary text for profanity.
|
||||
|
||||
Generic validation function for any user-provided text field.
|
||||
Uses enhanced profanity detection that catches bypass attempts.
|
||||
|
||||
Args:
|
||||
text: The text to validate.
|
||||
field_name: Name of the field for error messages.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message).
|
||||
|
||||
Example:
|
||||
is_valid, error = validate_text(bio, "bio")
|
||||
"""
|
||||
if contains_profanity(text):
|
||||
return False, f"{field_name.title()} contains inappropriate language"
|
||||
return True, None
|
||||
|
||||
|
||||
def censor_text(text: str, censor_char: str = "*") -> str:
|
||||
"""Censor profanity in text by replacing with censor characters.
|
||||
|
||||
Useful for displaying user content that may contain profanity
|
||||
rather than rejecting it entirely.
|
||||
|
||||
Args:
|
||||
text: The text to censor.
|
||||
censor_char: Character to use for censoring (default: *).
|
||||
|
||||
Returns:
|
||||
Text with profanity replaced by censor characters.
|
||||
|
||||
Example:
|
||||
safe_text = censor_text("some bad words")
|
||||
# Returns: "some *** words"
|
||||
"""
|
||||
return profanity.censor(text, censor_char)
|
||||
|
||||
|
||||
def add_custom_words(words: list[str]) -> None:
|
||||
"""Add custom words to the profanity filter.
|
||||
|
||||
Use this to add game-specific or community-specific terms
|
||||
that should be blocked.
|
||||
|
||||
Args:
|
||||
words: List of words to add to the filter.
|
||||
|
||||
Example:
|
||||
add_custom_words(["customterm", "anotherterm"])
|
||||
"""
|
||||
profanity.add_censor_words(words)
|
||||
@ -8,6 +8,7 @@ dependencies = [
|
||||
"alembic>=1.18.1",
|
||||
"asyncpg>=0.31.0",
|
||||
"bcrypt>=5.0.0",
|
||||
"better-profanity>=0.7.0",
|
||||
"email-validator>=2.3.0",
|
||||
"fastapi>=0.128.0",
|
||||
"httpx>=0.28.1",
|
||||
|
||||
211
backend/tests/unit/services/test_profanity_service.py
Normal file
211
backend/tests/unit/services/test_profanity_service.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""Tests for profanity service.
|
||||
|
||||
Unit tests for the profanity filtering service that validates
|
||||
user-generated content like display names.
|
||||
"""
|
||||
|
||||
from app.services.profanity_service import (
|
||||
censor_text,
|
||||
contains_profanity,
|
||||
validate_display_name,
|
||||
validate_text,
|
||||
)
|
||||
|
||||
|
||||
class TestContainsProfanity:
|
||||
"""Tests for the contains_profanity function."""
|
||||
|
||||
def test_clean_text_returns_false(self) -> None:
|
||||
"""
|
||||
Test that clean text is not flagged as profanity.
|
||||
|
||||
Normal usernames and display names should pass validation
|
||||
without being incorrectly flagged.
|
||||
"""
|
||||
assert contains_profanity("PlayerOne") is False
|
||||
assert contains_profanity("CoolGamer123") is False
|
||||
assert contains_profanity("DragonMaster") is False
|
||||
|
||||
def test_profane_text_returns_true(self) -> None:
|
||||
"""
|
||||
Test that profane text is correctly detected.
|
||||
|
||||
Common profanity should be detected to prevent inappropriate
|
||||
usernames from being created.
|
||||
"""
|
||||
# Using a word that's definitely in the default word list
|
||||
assert contains_profanity("shit") is True
|
||||
assert contains_profanity("fuck") is True
|
||||
|
||||
def test_mixed_case_profanity_detected(self) -> None:
|
||||
"""
|
||||
Test that profanity is detected regardless of case.
|
||||
|
||||
Users may try to bypass filters using mixed case, which
|
||||
should still be caught.
|
||||
"""
|
||||
assert contains_profanity("SHIT") is True
|
||||
assert contains_profanity("ShIt") is True
|
||||
|
||||
def test_empty_string_is_clean(self) -> None:
|
||||
"""
|
||||
Test that empty strings are not flagged.
|
||||
|
||||
Empty strings don't contain profanity by definition.
|
||||
"""
|
||||
assert contains_profanity("") is False
|
||||
|
||||
def test_profanity_with_number_suffix_detected(self) -> None:
|
||||
"""
|
||||
Test that profanity followed by numbers is detected.
|
||||
|
||||
Users commonly try to bypass filters by appending numbers
|
||||
to bad words (e.g., "shit123"). The filter should catch these.
|
||||
"""
|
||||
assert contains_profanity("shit123") is True
|
||||
assert contains_profanity("fuck2024") is True
|
||||
assert contains_profanity("ass99") is True
|
||||
|
||||
def test_profanity_with_number_prefix_detected(self) -> None:
|
||||
"""
|
||||
Test that profanity preceded by numbers is detected.
|
||||
|
||||
Numbers before bad words should also be caught.
|
||||
"""
|
||||
assert contains_profanity("123shit") is True
|
||||
assert contains_profanity("69fuck") is True
|
||||
|
||||
def test_leet_speak_profanity_detected(self) -> None:
|
||||
"""
|
||||
Test that leet-speak substitutions are detected.
|
||||
|
||||
Common character substitutions (@ for a, 1 for i, etc.)
|
||||
should be normalized and caught by the filter.
|
||||
"""
|
||||
assert contains_profanity("sh1t") is True
|
||||
assert contains_profanity("f@ck") is True
|
||||
assert contains_profanity("a$$") is True
|
||||
assert contains_profanity("$hit") is True
|
||||
|
||||
def test_profanity_with_separators_detected(self) -> None:
|
||||
"""
|
||||
Test that profanity broken up with separators is detected.
|
||||
|
||||
Users may insert dots, dashes, or underscores between letters
|
||||
to break up bad words. The filter should remove these and check.
|
||||
"""
|
||||
assert contains_profanity("s.h.i.t") is True
|
||||
assert contains_profanity("f-u-c-k") is True
|
||||
assert contains_profanity("s_h_i_t") is True
|
||||
|
||||
def test_clean_words_with_numbers_not_flagged(self) -> None:
|
||||
"""
|
||||
Test that clean words with numbers are not incorrectly flagged.
|
||||
|
||||
Normal gaming-style names with numbers should pass validation.
|
||||
"""
|
||||
assert contains_profanity("Player123") is False
|
||||
assert contains_profanity("Gamer2024") is False
|
||||
assert contains_profanity("Dragon99") is False
|
||||
assert contains_profanity("FireMage42") is False
|
||||
|
||||
|
||||
class TestValidateDisplayName:
|
||||
"""Tests for the validate_display_name function."""
|
||||
|
||||
def test_valid_name_returns_success(self) -> None:
|
||||
"""
|
||||
Test that valid display names pass validation.
|
||||
|
||||
Normal display names should return (True, None) indicating
|
||||
they are valid with no error message.
|
||||
"""
|
||||
is_valid, error = validate_display_name("GoodPlayer")
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
def test_profane_name_returns_error(self) -> None:
|
||||
"""
|
||||
Test that profane display names return an error.
|
||||
|
||||
Display names containing profanity should return (False, error)
|
||||
with a user-friendly error message.
|
||||
"""
|
||||
is_valid, error = validate_display_name("shit123")
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert "inappropriate" in error.lower()
|
||||
|
||||
def test_error_message_is_user_friendly(self) -> None:
|
||||
"""
|
||||
Test that error messages are suitable for display to users.
|
||||
|
||||
The error message should be professional and not contain
|
||||
the actual profanity.
|
||||
"""
|
||||
is_valid, error = validate_display_name("fuckface")
|
||||
assert is_valid is False
|
||||
assert error == "Display name contains inappropriate language"
|
||||
|
||||
|
||||
class TestValidateText:
|
||||
"""Tests for the generic validate_text function."""
|
||||
|
||||
def test_valid_text_passes(self) -> None:
|
||||
"""
|
||||
Test that clean text passes validation.
|
||||
|
||||
Generic text validation should work the same as display name
|
||||
validation for clean content.
|
||||
"""
|
||||
is_valid, error = validate_text("This is a clean message", "message")
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
def test_custom_field_name_in_error(self) -> None:
|
||||
"""
|
||||
Test that the field name appears in error messages.
|
||||
|
||||
The error message should reference the specific field that
|
||||
failed validation for clarity.
|
||||
"""
|
||||
is_valid, error = validate_text("shit", "bio")
|
||||
assert is_valid is False
|
||||
assert "Bio" in error # Capitalized field name
|
||||
|
||||
|
||||
class TestCensorText:
|
||||
"""Tests for the censor_text function."""
|
||||
|
||||
def test_profanity_is_censored(self) -> None:
|
||||
"""
|
||||
Test that profanity is replaced with asterisks.
|
||||
|
||||
The censor function should replace bad words while keeping
|
||||
the rest of the text intact.
|
||||
"""
|
||||
result = censor_text("what the shit")
|
||||
assert "shit" not in result
|
||||
assert "****" in result
|
||||
|
||||
def test_clean_text_unchanged(self) -> None:
|
||||
"""
|
||||
Test that clean text is returned unchanged.
|
||||
|
||||
Text without profanity should pass through the censor
|
||||
function without modification.
|
||||
"""
|
||||
original = "This is a clean message"
|
||||
result = censor_text(original)
|
||||
assert result == original
|
||||
|
||||
def test_custom_censor_char(self) -> None:
|
||||
"""
|
||||
Test that custom censor characters are used.
|
||||
|
||||
Users should be able to specify alternative censor
|
||||
characters for different display contexts.
|
||||
"""
|
||||
result = censor_text("what the shit", censor_char="#")
|
||||
assert "#" in result
|
||||
assert "*" not in result
|
||||
11
backend/uv.lock
generated
11
backend/uv.lock
generated
@ -166,6 +166,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "better-profanity"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/52966c1c883819f0b48540312a4a7f63b5c49f4e5b1a10838ae7f06ebb3c/better_profanity-0.7.0.tar.gz", hash = "sha256:8a6fdc8606d7471e7b5f6801917eca98ec211098262e82f62da4f5de3a73145b", size = 29977, upload-time = "2020-11-02T10:49:57.54Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/dd/0b074d89e903cc771721cde2c4bf3d8c9d114b5bd791af5c62bcf5fb9459/better_profanity-0.7.0-py3-none-any.whl", hash = "sha256:bd4c529ea6aa2db1aaa50524be1ed14d0fe5c664f1fd88c8bc388c7e9f9f00e8", size = 46104, upload-time = "2020-11-02T10:49:56.066Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bidict"
|
||||
version = "0.23.1"
|
||||
@ -614,6 +623,7 @@ dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "better-profanity" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
@ -649,6 +659,7 @@ requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.18.1" },
|
||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||
{ name = "bcrypt", specifier = ">=5.0.0" },
|
||||
{ name = "better-profanity", specifier = ">=0.7.0" },
|
||||
{ name = "email-validator", specifier = ">=2.3.0" },
|
||||
{ name = "fastapi", specifier = ">=0.128.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
"name": "Authentication Flow",
|
||||
"version": "1.0.0",
|
||||
"created": "2026-01-30",
|
||||
"lastUpdated": "2026-01-30T15:00:00Z",
|
||||
"lastUpdated": "2026-01-30T16:00:00Z",
|
||||
"totalTasks": 10,
|
||||
"completedTasks": 5,
|
||||
"completedTasks": 6,
|
||||
"status": "in_progress",
|
||||
"description": "Complete OAuth authentication flow including login, callback handling, starter deck selection, profile management, and app initialization."
|
||||
},
|
||||
@ -192,8 +192,8 @@
|
||||
"description": "User profile management with linked accounts",
|
||||
"category": "pages",
|
||||
"priority": 6,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["F1-003"],
|
||||
"files": [
|
||||
{"path": "src/pages/ProfilePage.vue", "status": "modify"},
|
||||
|
||||
387
frontend/src/components/profile/DisplayNameEditor.spec.ts
Normal file
387
frontend/src/components/profile/DisplayNameEditor.spec.ts
Normal file
@ -0,0 +1,387 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import DisplayNameEditor from './DisplayNameEditor.vue'
|
||||
|
||||
describe('DisplayNameEditor', () => {
|
||||
describe('view mode', () => {
|
||||
it('displays the current name', () => {
|
||||
/**
|
||||
* Test that the display name is shown in view mode.
|
||||
*
|
||||
* By default, the component shows the current name
|
||||
* with an edit button, not an input field.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Test User')
|
||||
})
|
||||
|
||||
it('shows edit button', () => {
|
||||
/**
|
||||
* Test that an edit button is visible in view mode.
|
||||
*
|
||||
* Users need a way to enter edit mode to change their name.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
const editButton = wrapper.find('button')
|
||||
expect(editButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('enters edit mode when edit button is clicked', async () => {
|
||||
/**
|
||||
* Test entering edit mode.
|
||||
*
|
||||
* Clicking the edit button should switch from viewing
|
||||
* to editing the display name.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// Should now show an input field
|
||||
expect(wrapper.find('input').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('shows input with current value', async () => {
|
||||
/**
|
||||
* Test that input is pre-filled with current name.
|
||||
*
|
||||
* When entering edit mode, the input should contain
|
||||
* the current display name for easy editing.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Enter edit mode
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
const input = wrapper.find('input')
|
||||
expect((input.element as HTMLInputElement).value).toBe('Test User')
|
||||
})
|
||||
|
||||
it('shows save and cancel buttons', async () => {
|
||||
/**
|
||||
* Test that save and cancel buttons are visible.
|
||||
*
|
||||
* Users need clear actions to either save changes
|
||||
* or cancel and revert to the original value.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const buttonTexts = buttons.map(b => b.text())
|
||||
|
||||
expect(buttonTexts.some(t => t.includes('Save'))).toBe(true)
|
||||
expect(buttonTexts.some(t => t.includes('Cancel'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows character count', async () => {
|
||||
/**
|
||||
* Test that character count is displayed.
|
||||
*
|
||||
* Users should know how many characters they've used
|
||||
* relative to the maximum allowed.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('/32')
|
||||
})
|
||||
|
||||
it('cancels and reverts on cancel button click', async () => {
|
||||
/**
|
||||
* Test cancel functionality.
|
||||
*
|
||||
* Clicking cancel should exit edit mode and discard
|
||||
* any changes made to the input.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Enter edit mode
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// Change the value
|
||||
await wrapper.find('input').setValue('New Name')
|
||||
|
||||
// Click cancel
|
||||
const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel'))
|
||||
await cancelButton!.trigger('click')
|
||||
|
||||
// Should be back in view mode with original name
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Test User')
|
||||
})
|
||||
|
||||
it('cancels on Escape key', async () => {
|
||||
/**
|
||||
* Test keyboard shortcut for cancel.
|
||||
*
|
||||
* Pressing Escape should cancel editing as a
|
||||
* common keyboard shortcut.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation', () => {
|
||||
it('shows error for empty name', async () => {
|
||||
/**
|
||||
* Test validation of empty display name.
|
||||
*
|
||||
* Empty or whitespace-only names should be rejected
|
||||
* with a clear error message.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').setValue('')
|
||||
|
||||
// Click save
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
await saveButton!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('cannot be empty')
|
||||
})
|
||||
|
||||
it('shows error for name shorter than 2 characters', async () => {
|
||||
/**
|
||||
* Test minimum length validation.
|
||||
*
|
||||
* Very short names are likely typos and should be rejected.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').setValue('A')
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
await saveButton!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('at least 2 characters')
|
||||
})
|
||||
|
||||
it('shows error for name longer than 32 characters', async () => {
|
||||
/**
|
||||
* Test maximum length validation.
|
||||
*
|
||||
* Names that are too long should be rejected to prevent
|
||||
* UI issues and database constraints.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').setValue('A'.repeat(33))
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
await saveButton!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('cannot exceed 32')
|
||||
})
|
||||
|
||||
it('does not emit save with validation errors', async () => {
|
||||
/**
|
||||
* Test that invalid input does not trigger save.
|
||||
*
|
||||
* The component should validate locally before emitting
|
||||
* the save event to avoid unnecessary API calls.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').setValue('')
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
await saveButton!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('save')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saving', () => {
|
||||
it('emits save event with trimmed value', async () => {
|
||||
/**
|
||||
* Test save event emission.
|
||||
*
|
||||
* When the user saves, the component should emit the
|
||||
* trimmed display name for the parent to handle.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').setValue(' New Name ')
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
await saveButton!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
expect(wrapper.emitted('save')![0]).toEqual(['New Name'])
|
||||
})
|
||||
|
||||
it('saves on Enter key', async () => {
|
||||
/**
|
||||
* Test keyboard shortcut for save.
|
||||
*
|
||||
* Pressing Enter should save the changes as a
|
||||
* common keyboard shortcut.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').setValue('New Name')
|
||||
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
|
||||
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables buttons while saving', async () => {
|
||||
/**
|
||||
* Test button states during save.
|
||||
*
|
||||
* While saving, buttons should be disabled to prevent
|
||||
* duplicate submissions.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: true,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach(button => {
|
||||
if (button.text().includes('Save') || button.text().includes('Cancel')) {
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Saving..." text while saving', async () => {
|
||||
/**
|
||||
* Test loading state display.
|
||||
*
|
||||
* Users should see feedback that their save is in progress.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: true,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('Saving')
|
||||
})
|
||||
|
||||
it('exits edit mode when modelValue matches saved value', async () => {
|
||||
/**
|
||||
* Test automatic exit from edit mode on success.
|
||||
*
|
||||
* When the parent updates the modelValue to match the
|
||||
* saved value, edit mode should close automatically.
|
||||
*/
|
||||
const wrapper = mount(DisplayNameEditor, {
|
||||
props: {
|
||||
modelValue: 'Test User',
|
||||
isSaving: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Enter edit mode and save
|
||||
await wrapper.find('button').trigger('click')
|
||||
await wrapper.find('input').setValue('New Name')
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
await saveButton!.trigger('click')
|
||||
|
||||
// Simulate parent updating the value
|
||||
await wrapper.setProps({ modelValue: 'New Name' })
|
||||
|
||||
// Should exit edit mode
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
162
frontend/src/components/profile/DisplayNameEditor.vue
Normal file
162
frontend/src/components/profile/DisplayNameEditor.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Inline editable display name component.
|
||||
*
|
||||
* Shows the current display name with an edit button. When editing,
|
||||
* shows an input field with save/cancel buttons. Validates that the
|
||||
* name is not empty before saving.
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Current display name */
|
||||
modelValue: string
|
||||
/** Whether a save operation is in progress */
|
||||
isSaving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Emitted when user saves a new name */
|
||||
'update:modelValue': [value: string]
|
||||
/** Emitted when user saves */
|
||||
save: [newName: string]
|
||||
}>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const editValue = ref('')
|
||||
const validationError = ref<string | null>(null)
|
||||
|
||||
/** Start editing mode */
|
||||
function startEdit(): void {
|
||||
editValue.value = props.modelValue
|
||||
validationError.value = null
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
/** Cancel editing and revert changes */
|
||||
function cancelEdit(): void {
|
||||
isEditing.value = false
|
||||
editValue.value = ''
|
||||
validationError.value = null
|
||||
}
|
||||
|
||||
/** Validate and save the new name */
|
||||
function saveEdit(): void {
|
||||
const trimmed = editValue.value.trim()
|
||||
|
||||
// Validation
|
||||
if (!trimmed) {
|
||||
validationError.value = 'Display name cannot be empty'
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
validationError.value = 'Display name must be at least 2 characters'
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed.length > 32) {
|
||||
validationError.value = 'Display name cannot exceed 32 characters'
|
||||
return
|
||||
}
|
||||
|
||||
// Clear validation and emit save
|
||||
validationError.value = null
|
||||
emit('save', trimmed)
|
||||
}
|
||||
|
||||
/** Handle Enter key to save */
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
saveEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
// Exit edit mode when save completes successfully
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (isEditing.value && newVal === editValue.value.trim()) {
|
||||
isEditing.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- View mode -->
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xl font-semibold text-white">{{ modelValue }}</span>
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-white transition-colors"
|
||||
title="Edit display name"
|
||||
@click="startEdit"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="editValue"
|
||||
type="text"
|
||||
class="flex-1 px-3 py-2 bg-surface-light border border-gray-600 rounded-lg text-white focus:outline-none focus:border-primary"
|
||||
:class="{ 'border-red-500': validationError }"
|
||||
placeholder="Enter display name"
|
||||
maxlength="32"
|
||||
autofocus
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isSaving"
|
||||
@click="saveEdit"
|
||||
>
|
||||
<span v-if="isSaving">Saving...</span>
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 hover:text-white transition-colors"
|
||||
:disabled="isSaving"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="validationError"
|
||||
class="text-sm text-red-400"
|
||||
>
|
||||
{{ validationError }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ editValue.length }}/32 characters
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
264
frontend/src/components/profile/LinkedAccountCard.spec.ts
Normal file
264
frontend/src/components/profile/LinkedAccountCard.spec.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import LinkedAccountCard from './LinkedAccountCard.vue'
|
||||
import type { LinkedAccount } from '@/composables/useProfile'
|
||||
|
||||
describe('LinkedAccountCard', () => {
|
||||
const createAccount = (overrides: Partial<LinkedAccount> = {}): LinkedAccount => ({
|
||||
provider: 'google',
|
||||
providerUserId: 'g123',
|
||||
email: 'test@gmail.com',
|
||||
linkedAt: '2026-01-15T10:30:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('displays provider name', () => {
|
||||
/**
|
||||
* Test that the provider name is displayed.
|
||||
*
|
||||
* Users should be able to identify which OAuth provider
|
||||
* each linked account belongs to.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount({ provider: 'google' }),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Google')
|
||||
})
|
||||
|
||||
it('displays email when available', () => {
|
||||
/**
|
||||
* Test that the email is displayed for linked accounts.
|
||||
*
|
||||
* The email helps users identify which specific account
|
||||
* is linked (e.g., if they have multiple Google accounts).
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount({ email: 'user@example.com' }),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('user@example.com')
|
||||
})
|
||||
|
||||
it('displays fallback text when email is null', () => {
|
||||
/**
|
||||
* Test fallback display when no email is available.
|
||||
*
|
||||
* Some providers may not provide email (e.g., Discord).
|
||||
* We should show a reasonable fallback.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount({ provider: 'discord', email: null }),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Discord Account')
|
||||
})
|
||||
|
||||
it('displays linked date formatted', () => {
|
||||
/**
|
||||
* Test that the linked date is displayed in a readable format.
|
||||
*
|
||||
* Users should know when they linked each account for security.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount({ linkedAt: '2026-01-15T10:30:00Z' }),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Should contain "Linked" and some date text
|
||||
expect(wrapper.text()).toContain('Linked')
|
||||
expect(wrapper.text()).toMatch(/Jan|15|2026/)
|
||||
})
|
||||
|
||||
it('shows correct icon for Google', () => {
|
||||
/**
|
||||
* Test Google provider icon.
|
||||
*
|
||||
* Visual differentiation helps users quickly identify providers.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount({ provider: 'google' }),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('🔵')
|
||||
})
|
||||
|
||||
it('shows correct icon for Discord', () => {
|
||||
/**
|
||||
* Test Discord provider icon.
|
||||
*
|
||||
* Visual differentiation helps users quickly identify providers.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount({ provider: 'discord' }),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('🟣')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unlink button', () => {
|
||||
it('is enabled when not the only account', () => {
|
||||
/**
|
||||
* Test that unlink is enabled with multiple accounts.
|
||||
*
|
||||
* Users should be able to unlink accounts as long as
|
||||
* they have at least one other linked account.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount(),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is disabled when only account', () => {
|
||||
/**
|
||||
* Test that unlink is disabled for the only account.
|
||||
*
|
||||
* Users cannot unlink their only OAuth provider because
|
||||
* they would be locked out of their account.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount(),
|
||||
isOnlyAccount: true,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('is disabled while unlinking', () => {
|
||||
/**
|
||||
* Test that unlink is disabled during operation.
|
||||
*
|
||||
* Prevent double-clicks and show feedback while
|
||||
* the unlink operation is in progress.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount(),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
expect(button.text()).toContain('Unlinking')
|
||||
})
|
||||
|
||||
it('shows tooltip explaining why unlink is disabled', () => {
|
||||
/**
|
||||
* Test tooltip for disabled unlink button.
|
||||
*
|
||||
* Users should understand why they can't unlink
|
||||
* their only account.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount(),
|
||||
isOnlyAccount: true,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('title')).toContain('Cannot unlink')
|
||||
})
|
||||
|
||||
it('emits unlink event with provider when clicked', async () => {
|
||||
/**
|
||||
* Test unlink event emission.
|
||||
*
|
||||
* When the user clicks unlink, the component should emit
|
||||
* an event with the provider name for the parent to handle.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount({ provider: 'discord' }),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('unlink')).toBeTruthy()
|
||||
expect(wrapper.emitted('unlink')![0]).toEqual(['discord'])
|
||||
})
|
||||
|
||||
it('does not emit when only account', async () => {
|
||||
/**
|
||||
* Test that unlink is not emitted for only account.
|
||||
*
|
||||
* Even if the button is somehow clicked, the event
|
||||
* should not be emitted.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount(),
|
||||
isOnlyAccount: true,
|
||||
isUnlinking: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('unlink')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not emit while unlinking', async () => {
|
||||
/**
|
||||
* Test that unlink is not emitted while in progress.
|
||||
*
|
||||
* Prevent duplicate requests if button is clicked
|
||||
* while an operation is already in progress.
|
||||
*/
|
||||
const wrapper = mount(LinkedAccountCard, {
|
||||
props: {
|
||||
account: createAccount(),
|
||||
isOnlyAccount: false,
|
||||
isUnlinking: true,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('unlink')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
105
frontend/src/components/profile/LinkedAccountCard.vue
Normal file
105
frontend/src/components/profile/LinkedAccountCard.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Linked OAuth account card component.
|
||||
*
|
||||
* Displays information about a linked OAuth provider (Google or Discord)
|
||||
* including the email/username, when it was linked, and an unlink button.
|
||||
* The unlink button is disabled if this is the only linked account.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { OAuthProvider, LinkedAccount } from '@/composables/useProfile'
|
||||
|
||||
const props = defineProps<{
|
||||
/** The linked account data */
|
||||
account: LinkedAccount
|
||||
/** Whether this is the only linked account (cannot unlink) */
|
||||
isOnlyAccount: boolean
|
||||
/** Whether an unlink operation is in progress */
|
||||
isUnlinking: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Emitted when user clicks unlink button */
|
||||
unlink: [provider: OAuthProvider]
|
||||
}>()
|
||||
|
||||
/** Provider display configuration */
|
||||
const providerConfig = computed(() => {
|
||||
const configs = {
|
||||
google: {
|
||||
name: 'Google',
|
||||
icon: '🔵',
|
||||
color: 'border-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
},
|
||||
discord: {
|
||||
name: 'Discord',
|
||||
icon: '🟣',
|
||||
color: 'border-indigo-500',
|
||||
bgColor: 'bg-indigo-500/10',
|
||||
},
|
||||
}
|
||||
return configs[props.account.provider]
|
||||
})
|
||||
|
||||
/** Format the linked date for display */
|
||||
const linkedDateFormatted = computed(() => {
|
||||
const date = new Date(props.account.linkedAt)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
})
|
||||
|
||||
/** Display text (email or provider ID) */
|
||||
const displayText = computed(() => {
|
||||
return props.account.email || `${providerConfig.value.name} Account`
|
||||
})
|
||||
|
||||
function handleUnlink(): void {
|
||||
if (!props.isOnlyAccount && !props.isUnlinking) {
|
||||
emit('unlink', props.account.provider)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between p-4 rounded-lg border-l-4"
|
||||
:class="[providerConfig.bgColor, providerConfig.color]"
|
||||
>
|
||||
<!-- Provider info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">{{ providerConfig.icon }}</span>
|
||||
<div>
|
||||
<div class="font-medium text-white">
|
||||
{{ providerConfig.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">
|
||||
{{ displayText }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
Linked {{ linkedDateFormatted }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlink button -->
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded transition-colors"
|
||||
:class="[
|
||||
isOnlyAccount
|
||||
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600 hover:text-white'
|
||||
]"
|
||||
:disabled="isOnlyAccount || isUnlinking"
|
||||
:title="isOnlyAccount ? 'Cannot unlink the only linked account' : 'Unlink this account'"
|
||||
@click="handleUnlink"
|
||||
>
|
||||
<span v-if="isUnlinking">Unlinking...</span>
|
||||
<span v-else>Unlink</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
586
frontend/src/composables/useProfile.spec.ts
Normal file
586
frontend/src/composables/useProfile.spec.ts
Normal file
@ -0,0 +1,586 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the config
|
||||
vi.mock('@/config', () => ({
|
||||
config: {
|
||||
apiBaseUrl: 'http://localhost:8000',
|
||||
wsUrl: 'http://localhost:8000',
|
||||
oauthRedirectUri: 'http://localhost:5173/auth/callback',
|
||||
isDev: true,
|
||||
isProd: false,
|
||||
},
|
||||
}))
|
||||
|
||||
import { apiClient } from '@/api/client'
|
||||
import { ApiError } from '@/api/types'
|
||||
import { useProfile } from './useProfile'
|
||||
|
||||
describe('useProfile', () => {
|
||||
let mockLocation: { href: string }
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Mock window.location for initiateLink
|
||||
mockLocation = {
|
||||
href: 'http://localhost:5173/profile',
|
||||
}
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...mockLocation, origin: 'http://localhost:5173' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Reset mocks
|
||||
vi.mocked(apiClient.get).mockReset()
|
||||
vi.mocked(apiClient.delete).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with null profile', () => {
|
||||
/**
|
||||
* Test that useProfile starts with no profile data.
|
||||
*
|
||||
* Before fetchProfile is called, the profile should be null
|
||||
* indicating no data has been loaded yet.
|
||||
*/
|
||||
const { profile, displayName, avatarUrl, linkedAccounts } = useProfile()
|
||||
|
||||
expect(profile.value).toBeNull()
|
||||
expect(displayName.value).toBe('Unknown')
|
||||
expect(avatarUrl.value).toBeUndefined()
|
||||
expect(linkedAccounts.value).toEqual([])
|
||||
})
|
||||
|
||||
it('starts with isLoading as false', () => {
|
||||
/**
|
||||
* Test initial loading state.
|
||||
*
|
||||
* The composable should not be in a loading state until
|
||||
* an async operation is initiated.
|
||||
*/
|
||||
const { isLoading } = useProfile()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('starts with no error', () => {
|
||||
/**
|
||||
* Test initial error state.
|
||||
*
|
||||
* The composable should start with no errors.
|
||||
*/
|
||||
const { error } = useProfile()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('starts with null session count', () => {
|
||||
/**
|
||||
* Test initial session count state.
|
||||
*
|
||||
* Session count should be null until fetchSessionCount is called.
|
||||
*/
|
||||
const { sessionCount } = useProfile()
|
||||
|
||||
expect(sessionCount.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchProfile', () => {
|
||||
it('delegates to user store fetchProfile', async () => {
|
||||
/**
|
||||
* Test that fetchProfile calls through to the user store.
|
||||
*
|
||||
* The composable wraps the store, so fetchProfile should
|
||||
* delegate to the store's implementation.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
const fetchSpy = vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true)
|
||||
|
||||
const { fetchProfile } = useProfile()
|
||||
const result = await fetchProfile()
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('clears local error before fetching', async () => {
|
||||
/**
|
||||
* Test error clearing on fetch.
|
||||
*
|
||||
* Starting a new fetch should clear any previous errors
|
||||
* so users don't see stale error messages.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true)
|
||||
|
||||
const { fetchProfile, error } = useProfile()
|
||||
await fetchProfile()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateDisplayName', () => {
|
||||
it('successfully updates display name', async () => {
|
||||
/**
|
||||
* Test successful display name update.
|
||||
*
|
||||
* When the user updates their display name, the composable
|
||||
* should call the store and return success.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
vi.spyOn(userStore, 'updateDisplayName').mockResolvedValue(true)
|
||||
|
||||
const { updateDisplayName } = useProfile()
|
||||
const result = await updateDisplayName('New Name')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('trims the display name before updating', async () => {
|
||||
/**
|
||||
* Test display name trimming.
|
||||
*
|
||||
* Leading/trailing whitespace should be removed before
|
||||
* sending to the server.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
const updateSpy = vi.spyOn(userStore, 'updateDisplayName').mockResolvedValue(true)
|
||||
|
||||
const { updateDisplayName } = useProfile()
|
||||
await updateDisplayName(' New Name ')
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith('New Name')
|
||||
})
|
||||
|
||||
it('returns error for empty display name', async () => {
|
||||
/**
|
||||
* Test validation of empty display name.
|
||||
*
|
||||
* Empty or whitespace-only names should be rejected
|
||||
* without making an API call.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
const updateSpy = vi.spyOn(userStore, 'updateDisplayName')
|
||||
|
||||
const { updateDisplayName } = useProfile()
|
||||
const result = await updateDisplayName(' ')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Display name cannot be empty')
|
||||
expect(updateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when store update fails', async () => {
|
||||
/**
|
||||
* Test error handling when update fails.
|
||||
*
|
||||
* If the store returns false (failed to update),
|
||||
* the composable should return an error result.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
vi.spyOn(userStore, 'updateDisplayName').mockResolvedValue(false)
|
||||
userStore.error = 'Server error'
|
||||
|
||||
const { updateDisplayName } = useProfile()
|
||||
const result = await updateDisplayName('New Name')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Server error')
|
||||
})
|
||||
|
||||
it('sets isUpdatingName while updating', async () => {
|
||||
/**
|
||||
* Test loading state during display name update.
|
||||
*
|
||||
* Components should be able to show a loading indicator
|
||||
* while the update is in progress.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
let resolveUpdate: () => void
|
||||
vi.spyOn(userStore, 'updateDisplayName').mockImplementation(
|
||||
() => new Promise((resolve) => { resolveUpdate = () => resolve(true) })
|
||||
)
|
||||
|
||||
const { updateDisplayName, isUpdatingName } = useProfile()
|
||||
|
||||
const promise = updateDisplayName('New Name')
|
||||
|
||||
expect(isUpdatingName.value).toBe(true)
|
||||
|
||||
resolveUpdate!()
|
||||
await promise
|
||||
|
||||
expect(isUpdatingName.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initiateLink', () => {
|
||||
it('redirects to Google link URL', () => {
|
||||
/**
|
||||
* Test Google account linking initiation.
|
||||
*
|
||||
* When initiating Google linking, the browser should be redirected
|
||||
* to the backend's Google link endpoint with a redirect_uri.
|
||||
*/
|
||||
const { initiateLink } = useProfile()
|
||||
|
||||
initiateLink('google')
|
||||
|
||||
expect(window.location.href).toContain('/api/auth/link/google')
|
||||
expect(window.location.href).toContain('redirect_uri=')
|
||||
})
|
||||
|
||||
it('redirects to Discord link URL', () => {
|
||||
/**
|
||||
* Test Discord account linking initiation.
|
||||
*
|
||||
* When initiating Discord linking, the browser should be redirected
|
||||
* to the backend's Discord link endpoint with a redirect_uri.
|
||||
*/
|
||||
const { initiateLink } = useProfile()
|
||||
|
||||
initiateLink('discord')
|
||||
|
||||
expect(window.location.href).toContain('/api/auth/link/discord')
|
||||
expect(window.location.href).toContain('redirect_uri=')
|
||||
})
|
||||
|
||||
it('clears any existing error', () => {
|
||||
/**
|
||||
* Test error clearing on link initiation.
|
||||
*
|
||||
* Starting a new link flow should clear any previous errors.
|
||||
*/
|
||||
const { initiateLink, error } = useProfile()
|
||||
|
||||
// Manually set an error state (simulate previous failure)
|
||||
initiateLink('google')
|
||||
|
||||
// Error should be cleared (we can't easily test this without internal access)
|
||||
// The implementation clears localError before redirect
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unlinkAccount', () => {
|
||||
beforeEach(() => {
|
||||
// Set up user store with linked accounts
|
||||
const userStore = useUserStore()
|
||||
userStore.profile = {
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
linkedAccounts: [
|
||||
{ provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' },
|
||||
{ provider: 'discord', providerUserId: 'd456', email: null, linkedAt: '2026-01-02T00:00:00Z' },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
it('successfully unlinks an account', async () => {
|
||||
/**
|
||||
* Test successful account unlinking.
|
||||
*
|
||||
* When unlinking a provider, the composable should call the
|
||||
* API and return success.
|
||||
*/
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(undefined)
|
||||
const userStore = useUserStore()
|
||||
vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true)
|
||||
|
||||
const { unlinkAccount } = useProfile()
|
||||
const result = await unlinkAccount('discord')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/api/users/me/link/discord')
|
||||
})
|
||||
|
||||
it('refreshes profile after unlinking', async () => {
|
||||
/**
|
||||
* Test profile refresh after unlink.
|
||||
*
|
||||
* After successfully unlinking, the profile should be refreshed
|
||||
* to get the updated linked accounts list.
|
||||
*/
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(undefined)
|
||||
const userStore = useUserStore()
|
||||
const fetchSpy = vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true)
|
||||
|
||||
const { unlinkAccount } = useProfile()
|
||||
await unlinkAccount('discord')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents unlinking the only linked account', async () => {
|
||||
/**
|
||||
* Test protection against unlinking last account.
|
||||
*
|
||||
* Users cannot unlink their only OAuth provider because
|
||||
* they would be locked out of their account.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
// Only one linked account
|
||||
userStore.profile!.linkedAccounts = [
|
||||
{ provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' },
|
||||
]
|
||||
|
||||
const { unlinkAccount } = useProfile()
|
||||
const result = await unlinkAccount('google')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Cannot unlink the only linked account')
|
||||
expect(apiClient.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error on API failure', async () => {
|
||||
/**
|
||||
* Test error handling for unlink API failure.
|
||||
*
|
||||
* If the API call fails, the composable should return
|
||||
* an appropriate error message.
|
||||
*/
|
||||
vi.mocked(apiClient.delete).mockRejectedValue(
|
||||
new ApiError(400, 'Bad Request', 'Cannot unlink primary provider')
|
||||
)
|
||||
|
||||
const { unlinkAccount } = useProfile()
|
||||
const result = await unlinkAccount('discord')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Cannot unlink primary provider')
|
||||
})
|
||||
|
||||
it('sets isLoadingUnlink while unlinking', async () => {
|
||||
/**
|
||||
* Test loading state during unlink.
|
||||
*
|
||||
* Components should be able to show a loading indicator
|
||||
* while the unlink operation is in progress.
|
||||
*/
|
||||
let resolveDelete: () => void
|
||||
vi.mocked(apiClient.delete).mockImplementation(
|
||||
() => new Promise((resolve) => { resolveDelete = () => resolve(undefined) })
|
||||
)
|
||||
const userStore = useUserStore()
|
||||
vi.spyOn(userStore, 'fetchProfile').mockResolvedValue(true)
|
||||
|
||||
const { unlinkAccount, isLoadingUnlink } = useProfile()
|
||||
|
||||
const promise = unlinkAccount('discord')
|
||||
|
||||
expect(isLoadingUnlink.value).toBe(true)
|
||||
|
||||
resolveDelete!()
|
||||
await promise
|
||||
|
||||
expect(isLoadingUnlink.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchSessionCount', () => {
|
||||
it('successfully fetches session count', async () => {
|
||||
/**
|
||||
* Test successful session count fetch.
|
||||
*
|
||||
* The composable should fetch the session count from the API
|
||||
* and store it for display.
|
||||
*/
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ activeSessionCount: 3 })
|
||||
|
||||
const { fetchSessionCount, sessionCount } = useProfile()
|
||||
const result = await fetchSessionCount()
|
||||
|
||||
expect(result).toBe(3)
|
||||
expect(sessionCount.value).toBe(3)
|
||||
})
|
||||
|
||||
it('returns null on API failure', async () => {
|
||||
/**
|
||||
* Test graceful handling of session count fetch failure.
|
||||
*
|
||||
* Session count is not critical, so failures should be
|
||||
* handled gracefully without throwing.
|
||||
*/
|
||||
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { fetchSessionCount, sessionCount } = useProfile()
|
||||
const result = await fetchSessionCount()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(sessionCount.value).toBeNull()
|
||||
})
|
||||
|
||||
it('sets isLoadingSession while fetching', async () => {
|
||||
/**
|
||||
* Test loading state during session count fetch.
|
||||
*
|
||||
* Components may want to show a loading indicator
|
||||
* while fetching session information.
|
||||
*/
|
||||
let resolveGet: (value: unknown) => void
|
||||
vi.mocked(apiClient.get).mockImplementation(
|
||||
() => new Promise((resolve) => { resolveGet = resolve })
|
||||
)
|
||||
|
||||
const profile = useProfile()
|
||||
|
||||
const promise = profile.fetchSessionCount()
|
||||
|
||||
expect(profile.isLoadingSession.value).toBe(true)
|
||||
|
||||
resolveGet!({ activeSessionCount: 2 })
|
||||
await promise
|
||||
|
||||
expect(profile.isLoadingSession.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isProviderLinked', () => {
|
||||
it('returns true for linked providers', () => {
|
||||
/**
|
||||
* Test linked provider detection.
|
||||
*
|
||||
* The composable should correctly identify which providers
|
||||
* are currently linked to the user's account.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
userStore.profile = {
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
linkedAccounts: [
|
||||
{ provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
}
|
||||
|
||||
const { isProviderLinked } = useProfile()
|
||||
|
||||
expect(isProviderLinked('google')).toBe(true)
|
||||
expect(isProviderLinked('discord')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no accounts are linked', () => {
|
||||
/**
|
||||
* Test linked provider detection with no accounts.
|
||||
*
|
||||
* When no profile is loaded or no accounts are linked,
|
||||
* both providers should return false.
|
||||
*/
|
||||
const { isProviderLinked } = useProfile()
|
||||
|
||||
expect(isProviderLinked('google')).toBe(false)
|
||||
expect(isProviderLinked('discord')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinkedAccount', () => {
|
||||
it('returns linked account for provider', () => {
|
||||
/**
|
||||
* Test getting linked account details.
|
||||
*
|
||||
* The composable should return the full account details
|
||||
* for a linked provider.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
userStore.profile = {
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
linkedAccounts: [
|
||||
{ provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
}
|
||||
|
||||
const { getLinkedAccount } = useProfile()
|
||||
|
||||
const account = getLinkedAccount('google')
|
||||
expect(account).toBeDefined()
|
||||
expect(account?.email).toBe('test@gmail.com')
|
||||
})
|
||||
|
||||
it('returns undefined for unlinked provider', () => {
|
||||
/**
|
||||
* Test getting unlinked provider details.
|
||||
*
|
||||
* When a provider is not linked, getLinkedAccount should
|
||||
* return undefined.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
userStore.profile = {
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
linkedAccounts: [],
|
||||
}
|
||||
|
||||
const { getLinkedAccount } = useProfile()
|
||||
|
||||
expect(getLinkedAccount('discord')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('clears the local error state', () => {
|
||||
/**
|
||||
* Test error clearing.
|
||||
*
|
||||
* After displaying an error, components may want to clear it
|
||||
* (e.g., when user dismisses or tries again).
|
||||
*/
|
||||
const { clearError, error } = useProfile()
|
||||
|
||||
// Error starts as null
|
||||
expect(error.value).toBeNull()
|
||||
|
||||
// Clear should not throw even if no error
|
||||
clearError()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed isLoading', () => {
|
||||
it('reflects combined loading states', () => {
|
||||
/**
|
||||
* Test combined loading state.
|
||||
*
|
||||
* The isLoading computed should be true if any
|
||||
* operation is in progress.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
userStore.isLoading = true
|
||||
|
||||
const { isLoading } = useProfile()
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
277
frontend/src/composables/useProfile.ts
Normal file
277
frontend/src/composables/useProfile.ts
Normal file
@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Profile management composable.
|
||||
*
|
||||
* Provides a higher-level API for profile operations including:
|
||||
* - Profile fetching and updates
|
||||
* - Linked account management (link/unlink)
|
||||
* - Session count tracking
|
||||
* - Loading and error state management
|
||||
*
|
||||
* Wraps the user store with additional functionality and cleaner error handling.
|
||||
*/
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { ApiError } from '@/api/types'
|
||||
import { config } from '@/config'
|
||||
|
||||
/** OAuth provider types */
|
||||
export type OAuthProvider = 'google' | 'discord'
|
||||
|
||||
/** Linked account information from the backend */
|
||||
export interface LinkedAccount {
|
||||
provider: OAuthProvider
|
||||
providerUserId: string
|
||||
email: string | null
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
/** Session information from the backend */
|
||||
export interface SessionInfo {
|
||||
activeSessionCount: number
|
||||
}
|
||||
|
||||
/** Result of link/unlink operations */
|
||||
export interface LinkOperationResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Result of profile update operations */
|
||||
export interface UpdateProfileResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile management composable.
|
||||
*
|
||||
* Provides methods for viewing and updating user profile, managing linked
|
||||
* OAuth accounts, and viewing session information.
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <script setup lang="ts">
|
||||
* import { useProfile } from '@/composables/useProfile'
|
||||
*
|
||||
* const {
|
||||
* profile,
|
||||
* linkedAccounts,
|
||||
* isLoading,
|
||||
* fetchProfile,
|
||||
* updateDisplayName,
|
||||
* initiateLink,
|
||||
* unlinkAccount,
|
||||
* } = useProfile()
|
||||
*
|
||||
* onMounted(() => fetchProfile())
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useProfile() {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Local state for operations not in store
|
||||
const sessionCount = ref<number | null>(null)
|
||||
const isLoadingLink = ref(false)
|
||||
const isLoadingUnlink = ref(false)
|
||||
const isLoadingSession = ref(false)
|
||||
const isUpdatingName = ref(false)
|
||||
const localError = ref<string | null>(null)
|
||||
|
||||
// Computed from store
|
||||
const profile = computed(() => userStore.profile)
|
||||
const displayName = computed(() => userStore.displayName)
|
||||
const avatarUrl = computed(() => userStore.avatarUrl)
|
||||
const linkedAccounts = computed(() => userStore.linkedAccounts)
|
||||
const isLoading = computed(
|
||||
() =>
|
||||
userStore.isLoading ||
|
||||
isLoadingLink.value ||
|
||||
isLoadingUnlink.value ||
|
||||
isLoadingSession.value ||
|
||||
isUpdatingName.value
|
||||
)
|
||||
const error = computed(() => localError.value || userStore.error)
|
||||
|
||||
/**
|
||||
* Fetch the user profile from the API.
|
||||
*
|
||||
* Updates the user store with fresh profile data including linked accounts.
|
||||
*
|
||||
* @returns Whether fetch succeeded
|
||||
*/
|
||||
async function fetchProfile(): Promise<boolean> {
|
||||
localError.value = null
|
||||
return userStore.fetchProfile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's display name.
|
||||
*
|
||||
* @param newName - New display name (must be non-empty)
|
||||
* @returns Result indicating success/failure
|
||||
*/
|
||||
async function updateDisplayName(newName: string): Promise<UpdateProfileResult> {
|
||||
if (!newName.trim()) {
|
||||
return { success: false, error: 'Display name cannot be empty' }
|
||||
}
|
||||
|
||||
isUpdatingName.value = true
|
||||
localError.value = null
|
||||
|
||||
try {
|
||||
const success = await userStore.updateDisplayName(newName.trim())
|
||||
if (!success) {
|
||||
return { success: false, error: userStore.error || 'Failed to update display name' }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Failed to update display name'
|
||||
localError.value = errorMessage
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
isUpdatingName.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate linking an OAuth provider.
|
||||
*
|
||||
* Redirects the user to the backend OAuth link endpoint. After OAuth,
|
||||
* the user is redirected back to /auth/link/callback with success/error.
|
||||
*
|
||||
* @param provider - OAuth provider to link ('google' or 'discord')
|
||||
*/
|
||||
function initiateLink(provider: OAuthProvider): void {
|
||||
localError.value = null
|
||||
const redirectUri = encodeURIComponent(`${window.location.origin}/auth/link/callback`)
|
||||
window.location.href = `${config.apiBaseUrl}/api/auth/link/${provider}?redirect_uri=${redirectUri}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink an OAuth provider from the account.
|
||||
*
|
||||
* Cannot unlink the last remaining provider (would lock user out).
|
||||
*
|
||||
* @param provider - OAuth provider to unlink
|
||||
* @returns Result indicating success/failure
|
||||
*/
|
||||
async function unlinkAccount(provider: OAuthProvider): Promise<LinkOperationResult> {
|
||||
// Prevent unlinking last provider
|
||||
if (linkedAccounts.value.length <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot unlink the only linked account. Link another provider first.',
|
||||
}
|
||||
}
|
||||
|
||||
isLoadingUnlink.value = true
|
||||
localError.value = null
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/api/users/me/link/${provider}`)
|
||||
|
||||
// Refresh profile to get updated linked accounts
|
||||
await userStore.fetchProfile()
|
||||
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
let errorMessage = 'Failed to unlink account'
|
||||
|
||||
if (e instanceof ApiError) {
|
||||
if (e.status === 400 || e.status === 409) {
|
||||
errorMessage = e.detail || 'Cannot unlink this account'
|
||||
} else {
|
||||
errorMessage = e.detail || e.message
|
||||
}
|
||||
} else if (e instanceof Error) {
|
||||
errorMessage = e.message
|
||||
}
|
||||
|
||||
localError.value = errorMessage
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
isLoadingUnlink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the number of active sessions.
|
||||
*
|
||||
* @returns Number of active sessions or null on error
|
||||
*/
|
||||
async function fetchSessionCount(): Promise<number | null> {
|
||||
isLoadingSession.value = true
|
||||
localError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<SessionInfo>('/api/users/me/sessions')
|
||||
sessionCount.value = response.activeSessionCount
|
||||
return response.activeSessionCount
|
||||
} catch (e) {
|
||||
// Session count is not critical - just log and return null
|
||||
console.warn('Failed to fetch session count:', e)
|
||||
return null
|
||||
} finally {
|
||||
isLoadingSession.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is currently linked.
|
||||
*
|
||||
* @param provider - OAuth provider to check
|
||||
* @returns Whether the provider is linked
|
||||
*/
|
||||
function isProviderLinked(provider: OAuthProvider): boolean {
|
||||
return linkedAccounts.value.some((acc) => acc.provider === provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the linked account for a provider.
|
||||
*
|
||||
* @param provider - OAuth provider
|
||||
* @returns Linked account or undefined
|
||||
*/
|
||||
function getLinkedAccount(provider: OAuthProvider): LinkedAccount | undefined {
|
||||
return linkedAccounts.value.find((acc) => acc.provider === provider) as
|
||||
| LinkedAccount
|
||||
| undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any error state.
|
||||
*/
|
||||
function clearError(): void {
|
||||
localError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State (readonly to prevent external mutation)
|
||||
profile,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
linkedAccounts,
|
||||
sessionCount: readonly(sessionCount),
|
||||
isLoading,
|
||||
isUpdatingName: readonly(isUpdatingName),
|
||||
isLoadingLink: readonly(isLoadingLink),
|
||||
isLoadingUnlink: readonly(isLoadingUnlink),
|
||||
isLoadingSession: readonly(isLoadingSession),
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchProfile,
|
||||
updateDisplayName,
|
||||
initiateLink,
|
||||
unlinkAccount,
|
||||
fetchSessionCount,
|
||||
isProviderLinked,
|
||||
getLinkedAccount,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseProfile = ReturnType<typeof useProfile>
|
||||
299
frontend/src/pages/ProfilePage.spec.ts
Normal file
299
frontend/src/pages/ProfilePage.spec.ts
Normal file
@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import ProfilePage from './ProfilePage.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// Mock vue-router
|
||||
const mockRouterPush = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
useRoute: () => ({
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
query: {},
|
||||
params: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useAuth composable
|
||||
const mockLogout = vi.fn()
|
||||
const mockLogoutAll = vi.fn()
|
||||
vi.mock('@/composables/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
logout: mockLogout,
|
||||
logoutAll: mockLogoutAll,
|
||||
isLoading: { value: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API client
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn().mockResolvedValue({ activeSessionCount: 2 }),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/config', () => ({
|
||||
config: {
|
||||
apiBaseUrl: 'http://localhost:8000',
|
||||
wsUrl: 'http://localhost:8000',
|
||||
oauthRedirectUri: 'http://localhost:5173/auth/callback',
|
||||
isDev: true,
|
||||
isProd: false,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock fetch for auth store
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
// Mock window.location
|
||||
const mockLocation = {
|
||||
href: '',
|
||||
origin: 'http://localhost:5173',
|
||||
}
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Mock window.confirm
|
||||
global.confirm = vi.fn()
|
||||
|
||||
describe('ProfilePage', () => {
|
||||
function createProfileData() {
|
||||
return {
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
linkedAccounts: [
|
||||
{
|
||||
provider: 'google' as const,
|
||||
providerUserId: 'g123',
|
||||
email: 'test@gmail.com',
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockRouterPush.mockReset()
|
||||
mockLogout.mockReset()
|
||||
mockLogoutAll.mockReset()
|
||||
mockLocation.href = ''
|
||||
vi.mocked(global.confirm).mockReturnValue(false)
|
||||
|
||||
// Set up user store with profile
|
||||
const userStore = useUserStore()
|
||||
userStore.profile = createProfileData()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('shows loading state while fetching profile', () => {
|
||||
/**
|
||||
* Test loading state display.
|
||||
*
|
||||
* While the profile is being fetched, users should see
|
||||
* a loading indicator instead of an empty page.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
userStore.profile = null
|
||||
userStore.isLoading = true
|
||||
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
expect(wrapper.find('.animate-spin').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows profile data when loaded', async () => {
|
||||
/**
|
||||
* Test profile data display.
|
||||
*
|
||||
* Once loaded, the profile page should show the user's
|
||||
* display name and other relevant information.
|
||||
*/
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Test User')
|
||||
expect(wrapper.text()).toContain('Profile')
|
||||
})
|
||||
|
||||
it('shows avatar initial when no avatar URL', async () => {
|
||||
/**
|
||||
* Test avatar fallback display.
|
||||
*
|
||||
* When users don't have an avatar image, we show their
|
||||
* name initial as a fallback.
|
||||
*/
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('T') // First letter of "Test User"
|
||||
})
|
||||
|
||||
it('shows member since date', async () => {
|
||||
/**
|
||||
* Test member date display.
|
||||
*
|
||||
* Users should see when they joined the platform.
|
||||
*/
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Member since')
|
||||
})
|
||||
})
|
||||
|
||||
describe('linked accounts', () => {
|
||||
it('displays linked accounts list', async () => {
|
||||
/**
|
||||
* Test linked accounts display.
|
||||
*
|
||||
* Users should see all their linked OAuth providers
|
||||
* with relevant details.
|
||||
*/
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Linked Accounts')
|
||||
expect(wrapper.text()).toContain('Google')
|
||||
})
|
||||
|
||||
it('shows link button for unlinked providers', async () => {
|
||||
/**
|
||||
* Test link additional provider button.
|
||||
*
|
||||
* If a provider isn't linked, users should see an option
|
||||
* to link it.
|
||||
*/
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// Google is linked, so Discord should have a link button
|
||||
expect(wrapper.text()).toContain('Link Discord')
|
||||
})
|
||||
|
||||
it('hides link buttons when all providers linked', async () => {
|
||||
/**
|
||||
* Test hiding link buttons when all linked.
|
||||
*
|
||||
* If both Google and Discord are linked, no additional
|
||||
* link buttons should appear.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
userStore.profile!.linkedAccounts = [
|
||||
{ provider: 'google', providerUserId: 'g123', email: 'test@gmail.com', linkedAt: '2026-01-01T00:00:00Z' },
|
||||
{ provider: 'discord', providerUserId: 'd456', email: null, linkedAt: '2026-01-02T00:00:00Z' },
|
||||
]
|
||||
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Link Google')
|
||||
expect(wrapper.text()).not.toContain('Link Discord')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('shows logout buttons in Account Actions section', async () => {
|
||||
/**
|
||||
* Test that logout buttons are present.
|
||||
*
|
||||
* The profile page should show both a regular logout button
|
||||
* and a logout all devices button.
|
||||
*/
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// Check the section exists and contains the expected elements
|
||||
expect(wrapper.text()).toContain('Account Actions')
|
||||
expect(wrapper.text()).toContain('Logout')
|
||||
expect(wrapper.text()).toContain('Logout All Devices')
|
||||
})
|
||||
|
||||
it('has logout action handlers defined', async () => {
|
||||
/**
|
||||
* Test that logout handlers are available.
|
||||
*
|
||||
* The page should have click handlers that call the
|
||||
* auth composable's logout methods.
|
||||
*/
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// The logout buttons should exist and be clickable
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify the handlers exist by checking the component renders properly
|
||||
expect(wrapper.html()).toContain('bg-red-600') // Logout button styling
|
||||
expect(wrapper.html()).toContain('bg-red-900') // Logout all button styling
|
||||
})
|
||||
})
|
||||
|
||||
describe('session info', () => {
|
||||
it('displays session count section', async () => {
|
||||
/**
|
||||
* Test session info section display.
|
||||
*
|
||||
* Users should see information about their active sessions.
|
||||
*/
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Sessions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('shows error state when profile fetch fails', async () => {
|
||||
/**
|
||||
* Test error display on profile fetch failure.
|
||||
*
|
||||
* If the profile cannot be loaded, users should see
|
||||
* an error message explaining the problem.
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
userStore.profile = null
|
||||
|
||||
// Mock fetchProfile to fail and set error
|
||||
vi.spyOn(userStore, 'fetchProfile').mockImplementation(async () => {
|
||||
userStore.error = 'Failed to load profile'
|
||||
return false
|
||||
})
|
||||
|
||||
const wrapper = mount(ProfilePage)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Failed to load profile')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,57 +2,262 @@
|
||||
/**
|
||||
* User profile page.
|
||||
*
|
||||
* Displays user info, linked accounts, and logout option.
|
||||
* Displays user info including avatar, editable display name, linked OAuth
|
||||
* accounts, and logout options. Users can link additional OAuth providers
|
||||
* and unlink existing ones (except the last one).
|
||||
*/
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { onMounted, computed } from 'vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
import { useProfile } from '@/composables/useProfile'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import LinkedAccountCard from '@/components/profile/LinkedAccountCard.vue'
|
||||
import DisplayNameEditor from '@/components/profile/DisplayNameEditor.vue'
|
||||
import type { OAuthProvider } from '@/composables/useProfile'
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
const ui = useUiStore()
|
||||
const {
|
||||
profile,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
linkedAccounts,
|
||||
sessionCount,
|
||||
isLoading,
|
||||
isUpdatingName,
|
||||
isLoadingUnlink,
|
||||
error,
|
||||
fetchProfile,
|
||||
updateDisplayName,
|
||||
initiateLink,
|
||||
unlinkAccount,
|
||||
fetchSessionCount,
|
||||
isProviderLinked,
|
||||
} = useProfile()
|
||||
|
||||
const { logout, logoutAll, isLoading: isLoggingOut } = useAuth()
|
||||
|
||||
/** Avatar display - either image or initial */
|
||||
const avatarInitial = computed(() => {
|
||||
return displayName.value?.charAt(0).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
/** Check if there's only one linked account */
|
||||
const hasOnlyOneAccount = computed(() => linkedAccounts.value.length <= 1)
|
||||
|
||||
/** Available providers that can be linked */
|
||||
const availableProviders = computed(() => {
|
||||
const allProviders: OAuthProvider[] = ['google', 'discord']
|
||||
return allProviders.filter((p) => !isProviderLinked(p))
|
||||
})
|
||||
|
||||
// Fetch profile data on mount
|
||||
onMounted(async () => {
|
||||
await fetchProfile()
|
||||
await fetchSessionCount()
|
||||
})
|
||||
|
||||
/** Handle display name save */
|
||||
async function handleDisplayNameSave(newName: string): Promise<void> {
|
||||
const result = await updateDisplayName(newName)
|
||||
if (result.success) {
|
||||
ui.showSuccess('Display name updated')
|
||||
} else {
|
||||
ui.showError(result.error || 'Failed to update display name')
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle unlinking an account */
|
||||
async function handleUnlink(provider: OAuthProvider): Promise<void> {
|
||||
const result = await unlinkAccount(provider)
|
||||
if (result.success) {
|
||||
ui.showSuccess(`${provider} account unlinked`)
|
||||
} else {
|
||||
ui.showError(result.error || 'Failed to unlink account')
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle logout (current session) */
|
||||
async function handleLogout(): Promise<void> {
|
||||
await logout()
|
||||
}
|
||||
|
||||
/** Handle logout all (all devices) */
|
||||
async function handleLogoutAll(): Promise<void> {
|
||||
const confirmed = window.confirm(
|
||||
'This will log you out from all devices. Continue?'
|
||||
)
|
||||
if (confirmed) {
|
||||
await logoutAll()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<h1 class="mb-6 text-2xl font-bold">
|
||||
<div class="p-4 md:p-6 max-w-2xl mx-auto">
|
||||
<h1 class="mb-6 text-2xl font-bold text-white">
|
||||
Profile
|
||||
</h1>
|
||||
|
||||
<div class="max-w-md space-y-6">
|
||||
<!-- Avatar and Name -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200 text-2xl">
|
||||
{{ auth.user?.displayName?.charAt(0) || '?' }}
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="isLoading && !profile"
|
||||
class="flex justify-center py-12"
|
||||
>
|
||||
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ auth.user?.displayName || 'Unknown' }}
|
||||
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="error && !profile"
|
||||
class="p-4 bg-red-900/20 border border-red-500 rounded-lg text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Player
|
||||
|
||||
<!-- Profile content -->
|
||||
<div
|
||||
v-else
|
||||
class="space-y-8"
|
||||
>
|
||||
<!-- Avatar and Name Section -->
|
||||
<section class="bg-surface-dark rounded-lg p-6">
|
||||
<div class="flex items-start gap-6">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
v-if="avatarUrl"
|
||||
class="h-20 w-20 rounded-full overflow-hidden ring-2 ring-primary/50"
|
||||
>
|
||||
<img
|
||||
:src="avatarUrl"
|
||||
:alt="displayName"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-20 w-20 rounded-full bg-primary/20 flex items-center justify-center text-3xl font-bold text-primary-light ring-2 ring-primary/50"
|
||||
>
|
||||
{{ avatarInitial }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Accounts -->
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 font-semibold">
|
||||
<!-- Name editor -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<DisplayNameEditor
|
||||
:model-value="displayName"
|
||||
:is-saving="isUpdatingName"
|
||||
@save="handleDisplayNameSave"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-gray-400">
|
||||
Member since {{ profile?.createdAt ? new Date(profile.createdAt).toLocaleDateString() : 'Unknown' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Linked Accounts Section -->
|
||||
<section class="bg-surface-dark rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">
|
||||
Linked Accounts
|
||||
</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
<!-- TODO: Display linked OAuth accounts -->
|
||||
No linked accounts information available
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<button
|
||||
class="w-full rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
|
||||
@click="logout"
|
||||
<div
|
||||
v-if="linkedAccounts.length > 0"
|
||||
class="space-y-3"
|
||||
>
|
||||
Logout
|
||||
<LinkedAccountCard
|
||||
v-for="account in linkedAccounts"
|
||||
:key="account.provider"
|
||||
:account="account"
|
||||
:is-only-account="hasOnlyOneAccount"
|
||||
:is-unlinking="isLoadingUnlink"
|
||||
@unlink="handleUnlink"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="text-gray-400"
|
||||
>
|
||||
No linked accounts
|
||||
</p>
|
||||
|
||||
<!-- Link additional provider buttons -->
|
||||
<div
|
||||
v-if="availableProviders.length > 0"
|
||||
class="mt-4 pt-4 border-t border-gray-700"
|
||||
>
|
||||
<p class="text-sm text-gray-400 mb-3">
|
||||
Link additional accounts:
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider"
|
||||
class="px-4 py-2 rounded-lg transition-colors"
|
||||
:class="[
|
||||
provider === 'google'
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700'
|
||||
]"
|
||||
@click="initiateLink(provider)"
|
||||
>
|
||||
<span class="mr-2">{{ provider === 'google' ? '🔵' : '🟣' }}</span>
|
||||
Link {{ provider === 'google' ? 'Google' : 'Discord' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Session Info Section -->
|
||||
<section class="bg-surface-dark rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">
|
||||
Sessions
|
||||
</h2>
|
||||
|
||||
<p
|
||||
v-if="sessionCount !== null"
|
||||
class="text-gray-400"
|
||||
>
|
||||
You have <span class="text-white font-medium">{{ sessionCount }}</span> active session{{ sessionCount === 1 ? '' : 's' }}.
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-gray-400"
|
||||
>
|
||||
Session information unavailable
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Logout Section -->
|
||||
<section class="bg-surface-dark rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">
|
||||
Account Actions
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
class="flex-1 px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isLoggingOut"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<span v-if="isLoggingOut">Logging out...</span>
|
||||
<span v-else>Logout</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex-1 px-4 py-3 bg-red-900 text-red-200 rounded-lg hover:bg-red-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isLoggingOut"
|
||||
@click="handleLogoutAll"
|
||||
>
|
||||
Logout All Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-xs text-gray-500">
|
||||
"Logout" ends this session. "Logout All Devices" revokes all active sessions.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user