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:
Cal Corum 2026-01-30 16:06:42 -06:00
parent 3cc8d6645e
commit cd3efcb528
14 changed files with 2780 additions and 41 deletions

View File

@ -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)

View 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)

View File

@ -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",

View 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
View File

@ -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" },

View File

@ -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"},

View 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)
})
})
})

View 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>

View 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()
})
})
})

View 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>

View 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)
})
})
})

View 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>

View 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')
})
})
})

View File

@ -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) || '?' }}
</div>
<div>
<div class="text-lg font-semibold">
{{ auth.user?.displayName || 'Unknown' }}
</div>
<div class="text-sm text-gray-500">
Player
</div>
</div>
</div>
<!-- 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>
<!-- Linked Accounts -->
<div class="rounded-lg border p-4">
<h2 class="mb-4 font-semibold">
<!-- 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>
<!-- 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>
<!-- 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"
>
Logout
</button>
<div
v-if="linkedAccounts.length > 0"
class="space-y-3"
>
<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>