From 86aff695df0c619a3594c8df26f389c9c3f21053 Mon Sep 17 00:00:00 2001 From: Iu Date: Mon, 12 Oct 2026 14:26:32 +0300 Subject: [PATCH] feat(auth): add user profile, phone login, and account deletion --- README.md | 11 +- .../java/com/galo/android/ui/GaloNavHost.kt | 14 + .../galo/android/ui/GaloViewModelFactory.kt | 3 + .../galo/android/ui/albums/AlbumsScreen.kt | 7 + .../com/galo/android/ui/auth/AuthScreen.kt | 88 +++++-- .../com/galo/android/ui/auth/AuthViewModel.kt | 34 ++- .../galo/android/ui/profile/ProfileScreen.kt | 246 ++++++++++++++++++ .../android/ui/profile/ProfileViewModel.kt | 170 ++++++++++++ .../android/ui/albums/AlbumsContentTest.kt | 3 + .../galo/android/ui/auth/AuthContentTest.kt | 52 ++++ .../android/ui/profile/ProfileContentTest.kt | 94 +++++++ .../ui/profile/ProfileViewModelTest.kt | 115 ++++++++ backend/internal/app/app.go | 4 + backend/internal/auth/handlers.go | 97 ++++++- backend/internal/auth/handlers_test.go | 215 +++++++++++++++ backend/internal/auth/identifiers.go | 63 +++++ backend/internal/auth/identifiers_test.go | 63 +++++ backend/internal/auth/repository.go | 7 + backend/internal/auth/service.go | 202 ++++++++++++-- backend/internal/auth/service_test.go | 239 +++++++++++++++++ backend/internal/auth/sqlite_store.go | 74 +++++- backend/internal/auth/store.go | 102 ++++++-- backend/internal/e2e/auth_flow_test.go | 132 ++++++++++ backend/internal/e2e/sqlite_test.go | 114 +++++++- backend/internal/e2e/trash_test.go | 12 +- backend/internal/e2e/video_test.go | 2 +- backend/internal/models/models.go | 18 ++ backend/internal/models/models_test.go | 30 +++ backend/internal/repo/contract/contract.go | 40 +++ backend/migrations/000013_user_phone.down.sql | 3 + backend/migrations/000013_user_phone.up.sql | 6 + .../sqlite/000005_user_phone.down.sql | 21 ++ .../sqlite/000005_user_phone.up.sql | 20 ++ frontend/src/lib/api.ts | 16 +- frontend/src/lib/components/AppShell.svelte | 9 +- frontend/src/lib/stores/auth.svelte.ts | 3 +- frontend/src/lib/types.ts | 1 + .../src/routes/(app)/profile/+page.svelte | 226 ++++++++++++++++ .../src/routes/(public)/login/+page.svelte | 46 +++- .../changes/add-user-profile/.openspec.yaml | 2 + openspec/changes/add-user-profile/design.md | 116 +++++++++ openspec/changes/add-user-profile/proposal.md | 57 ++++ .../add-user-profile/specs/auth/spec.md | 41 +++ .../specs/user-profile/spec.md | 120 +++++++++ openspec/changes/add-user-profile/tasks.md | 51 ++++ scripts/seed.py | 38 +-- 46 files changed, 2893 insertions(+), 134 deletions(-) create mode 100644 android/app/src/main/java/com/galo/android/ui/profile/ProfileScreen.kt create mode 100644 android/app/src/main/java/com/galo/android/ui/profile/ProfileViewModel.kt create mode 100644 android/app/src/test/java/com/galo/android/ui/auth/AuthContentTest.kt create mode 100644 android/app/src/test/java/com/galo/android/ui/profile/ProfileContentTest.kt create mode 100644 android/app/src/test/java/com/galo/android/ui/profile/ProfileViewModelTest.kt create mode 100644 backend/internal/auth/handlers_test.go create mode 100644 backend/internal/auth/identifiers.go create mode 100644 backend/internal/auth/identifiers_test.go create mode 100644 backend/internal/auth/service_test.go create mode 100644 backend/internal/e2e/auth_flow_test.go create mode 100644 backend/internal/models/models_test.go create mode 100644 backend/migrations/000013_user_phone.down.sql create mode 100644 backend/migrations/000013_user_phone.up.sql create mode 100644 backend/migrations/sqlite/000005_user_phone.down.sql create mode 100644 backend/migrations/sqlite/000005_user_phone.up.sql create mode 100644 frontend/src/routes/(app)/profile/+page.svelte create mode 100644 openspec/changes/add-user-profile/.openspec.yaml create mode 100644 openspec/changes/add-user-profile/design.md create mode 100644 openspec/changes/add-user-profile/proposal.md create mode 100644 openspec/changes/add-user-profile/specs/auth/spec.md create mode 100644 openspec/changes/add-user-profile/specs/user-profile/spec.md create mode 100644 openspec/changes/add-user-profile/tasks.md diff --git a/README.md b/README.md index 6908c62..c9c4655 100644 --- a/README.md +++ b/README.md @@ -196,9 +196,13 @@ GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \ | Переменная | По умолчанию | Описание | | --- | --- | --- | | `GALO_BASE_URL` | `http://localhost:8080` | Базовый URL инстанса | -| `GALO_EMAIL` | — | Email владельца (обязательно) | +| `GALO_EMAIL` | — | Email владельца (нужен один из email/телефона) | +| `GALO_PHONE` | — | Телефон владельца в E.164 (нужен один из email/телефона) | | `GALO_PASSWORD` | — | Пароль владельца (обязательно) | +Вход выполняется по единому идентификатору: при заданном `GALO_EMAIL` используется он, +иначе — `GALO_PHONE`. + Параметры: | Флаг | По умолчанию | Описание | @@ -300,7 +304,10 @@ deploy/ # systemd unit, пример nginx Полный список эндпоинтов: `project.md`, раздел 11. Кратко: -- `POST /api/auth/register|login|refresh|logout`, `GET /api/auth/me` +- `POST /api/auth/register` (email и/или phone), `POST /api/auth/login` + (`identifier` — email или E.164-телефон), `POST /api/auth/refresh|logout` +- `GET|PATCH|DELETE /api/auth/me`, `POST /api/auth/me/password`, + `POST /api/auth/me/identifiers`, `PATCH /api/auth/me/preferences` - `GET|POST /api/albums`, `GET|PATCH|DELETE /api/albums/:id` - `GET /api/albums/:id/members`, `POST /api/albums/:id/invites`, `POST /api/invites/:token/accept`, `PATCH|DELETE /api/albums/:id/members/:user_id` diff --git a/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt b/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt index d9b31fa..94bb537 100644 --- a/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt +++ b/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt @@ -18,6 +18,7 @@ import com.galo.android.data.prefs.SettingsState import com.galo.android.ui.album.AlbumScreen import com.galo.android.ui.albums.AlbumsScreen import com.galo.android.ui.auth.AuthScreen +import com.galo.android.ui.profile.ProfileScreen import com.galo.android.ui.setup.SetupScreen import com.galo.android.ui.upload.UploadScreen import com.galo.android.ui.viewer.ViewerScreen @@ -27,6 +28,7 @@ object Routes { const val AUTH = "auth" const val ALBUMS = "albums" const val UPLOADS = "uploads" + const val PROFILE = "profile" const val ALBUM = "album/{albumId}" const val VIEWER = "album/{albumId}/viewer?index={index}" @@ -82,6 +84,7 @@ fun GaloNavHost(app: GaloApplication) { factory = factory, onOpenAlbum = { id -> navController.navigate(Routes.album(id)) }, onOpenUploads = { navController.navigate(Routes.UPLOADS) }, + onOpenProfile = { navController.navigate(Routes.PROFILE) }, onLoggedOut = { navController.navigate(Routes.AUTH) { popUpTo(Routes.ALBUMS) { inclusive = true } @@ -89,6 +92,17 @@ fun GaloNavHost(app: GaloApplication) { }, ) } + composable(Routes.PROFILE) { + ProfileScreen( + factory = factory, + onLoggedOut = { + navController.navigate(Routes.AUTH) { + popUpTo(Routes.ALBUMS) { inclusive = true } + } + }, + onBack = { navController.popBackStack() }, + ) + } composable(Routes.UPLOADS) { UploadScreen( factory = factory, diff --git a/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt b/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt index 9a90f50..7e4f657 100644 --- a/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt +++ b/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider import com.galo.android.GaloApplication import com.galo.android.ui.albums.AlbumsViewModel import com.galo.android.ui.auth.AuthViewModel +import com.galo.android.ui.profile.ProfileViewModel import com.galo.android.ui.setup.SetupViewModel import com.galo.android.ui.upload.UploadViewModel @@ -19,6 +20,8 @@ class GaloViewModelFactory(private val app: GaloApplication) : ViewModelProvider modelClass.isAssignableFrom(AuthViewModel::class.java) -> AuthViewModel(container) modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container) modelClass.isAssignableFrom(UploadViewModel::class.java) -> UploadViewModel(container) + modelClass.isAssignableFrom(ProfileViewModel::class.java) -> + ProfileViewModel(container.api, container.tokenStore) else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}") } return viewModel as T diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt index 21822dd..46bde2b 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -50,6 +51,7 @@ fun AlbumsScreen( factory: GaloViewModelFactory, onOpenAlbum: (String) -> Unit, onOpenUploads: () -> Unit, + onOpenProfile: () -> Unit, onLoggedOut: () -> Unit, ) { val viewModel: AlbumsViewModel = viewModel(factory = factory) @@ -58,6 +60,7 @@ fun AlbumsScreen( state = state, onOpenAlbum = onOpenAlbum, onOpenUploads = onOpenUploads, + onOpenProfile = onOpenProfile, onRefresh = viewModel::refresh, onLogout = { viewModel.logout(onLoggedOut) }, ) @@ -69,6 +72,7 @@ fun AlbumsContent( state: AlbumsViewModel.UiState, onOpenAlbum: (String) -> Unit, onOpenUploads: () -> Unit, + onOpenProfile: () -> Unit, onRefresh: () -> Unit, onLogout: () -> Unit, ) { @@ -77,6 +81,9 @@ fun AlbumsContent( TopAppBar( title = { Text("Альбомы") }, actions = { + IconButton(onClick = onOpenProfile) { + Icon(Icons.Filled.Person, contentDescription = "Профиль") + } IconButton(onClick = onOpenUploads) { Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки") } diff --git a/android/app/src/main/java/com/galo/android/ui/auth/AuthScreen.kt b/android/app/src/main/java/com/galo/android/ui/auth/AuthScreen.kt index dbcfb57..edac44d 100644 --- a/android/app/src/main/java/com/galo/android/ui/auth/AuthScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/auth/AuthScreen.kt @@ -22,10 +22,13 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.galo.android.ui.GaloViewModelFactory +const val TAG_AUTH_IDENTIFIER = "auth_identifier" const val TAG_AUTH_EMAIL = "auth_email" +const val TAG_AUTH_PHONE = "auth_phone" const val TAG_AUTH_PASSWORD = "auth_password" const val TAG_AUTH_NAME = "auth_name" const val TAG_AUTH_SUBMIT = "auth_submit" +const val TAG_AUTH_TOGGLE = "auth_toggle" @Composable fun AuthScreen( @@ -39,6 +42,37 @@ fun AuthScreen( viewModel.authenticated.collect { onAuthenticated() } } + AuthContent( + state = state, + onIdentifierChange = viewModel::onIdentifierChange, + onEmailChange = viewModel::onEmailChange, + onPhoneChange = viewModel::onPhoneChange, + onPasswordChange = viewModel::onPasswordChange, + onNameChange = viewModel::onNameChange, + onSubmit = viewModel::submit, + onToggleMode = { + viewModel.setMode( + if (state.mode == AuthViewModel.Mode.LOGIN) { + AuthViewModel.Mode.REGISTER + } else { + AuthViewModel.Mode.LOGIN + }, + ) + }, + ) +} + +@Composable +fun AuthContent( + state: AuthViewModel.UiState, + onIdentifierChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPhoneChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onNameChange: (String) -> Unit, + onSubmit: () -> Unit, + onToggleMode: () -> Unit, +) { Column( modifier = Modifier .fillMaxWidth() @@ -52,23 +86,44 @@ fun AuthScreen( if (state.mode == AuthViewModel.Mode.REGISTER) { OutlinedTextField( value = state.name, - onValueChange = viewModel::onNameChange, + onValueChange = onNameChange, label = { Text("Имя") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_NAME), ) + OutlinedTextField( + value = state.email, + onValueChange = onEmailChange, + label = { Text("Email (необязательно)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_EMAIL), + ) + OutlinedTextField( + value = state.phone, + onValueChange = onPhoneChange, + label = { Text("Телефон (необязательно)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_PHONE), + ) + Text( + "Нужен хотя бы один из email или телефона", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + OutlinedTextField( + value = state.identifier, + onValueChange = onIdentifierChange, + label = { Text("Email или телефон") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_IDENTIFIER), + ) } - OutlinedTextField( - value = state.email, - onValueChange = viewModel::onEmailChange, - label = { Text("Email") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_EMAIL), - ) OutlinedTextField( value = state.password, - onValueChange = viewModel::onPasswordChange, + onValueChange = onPasswordChange, label = { Text("Пароль") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -79,7 +134,7 @@ fun AuthScreen( Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) } Button( - onClick = viewModel::submit, + onClick = onSubmit, enabled = !state.loading, modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_SUBMIT), ) { @@ -93,15 +148,8 @@ fun AuthScreen( } Row { TextButton( - onClick = { - viewModel.setMode( - if (state.mode == AuthViewModel.Mode.LOGIN) { - AuthViewModel.Mode.REGISTER - } else { - AuthViewModel.Mode.LOGIN - }, - ) - }, + onClick = onToggleMode, + modifier = Modifier.testTag(TAG_AUTH_TOGGLE), ) { Text( if (state.mode == AuthViewModel.Mode.LOGIN) { diff --git a/android/app/src/main/java/com/galo/android/ui/auth/AuthViewModel.kt b/android/app/src/main/java/com/galo/android/ui/auth/AuthViewModel.kt index c028c7e..fd0aaa0 100644 --- a/android/app/src/main/java/com/galo/android/ui/auth/AuthViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/auth/AuthViewModel.kt @@ -18,7 +18,9 @@ class AuthViewModel(private val container: AppContainer) : ViewModel() { data class UiState( val mode: Mode = Mode.LOGIN, + val identifier: String = "", val email: String = "", + val phone: String = "", val password: String = "", val name: String = "", val loading: Boolean = false, @@ -31,8 +33,13 @@ class AuthViewModel(private val container: AppContainer) : ViewModel() { private val _authenticated = MutableSharedFlow(extraBufferCapacity = 1) val authenticated = _authenticated.asSharedFlow() + fun onIdentifierChange(value: String) = + _state.update { it.copy(identifier = value, error = null) } + fun onEmailChange(value: String) = _state.update { it.copy(email = value, error = null) } + fun onPhoneChange(value: String) = _state.update { it.copy(phone = value, error = null) } + fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) } fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null) } @@ -41,25 +48,30 @@ class AuthViewModel(private val container: AppContainer) : ViewModel() { fun submit() { val current = _state.value + val identifier = current.identifier.trim() val email = current.email.trim() - when { - email.isEmpty() || current.password.isEmpty() -> { - _state.update { it.copy(error = "Заполните email и пароль") } - return - } + val phone = current.phone.trim() - current.mode == Mode.REGISTER && current.name.trim().isEmpty() -> { - _state.update { it.copy(error = "Укажите имя") } - return - } + val validationError = when { + current.password.isEmpty() -> "Введите пароль" + current.mode == Mode.LOGIN && identifier.isEmpty() -> "Введите email или телефон" + current.mode == Mode.REGISTER && email.isEmpty() && phone.isEmpty() -> + "Укажите email или телефон" + current.mode == Mode.REGISTER && current.name.trim().isEmpty() -> "Укажите имя" + else -> null + } + if (validationError != null) { + _state.update { it.copy(error = validationError) } + return } _state.update { it.copy(loading = true, error = null) } viewModelScope.launch { try { val tokens = when (current.mode) { - Mode.LOGIN -> container.api.login(email, current.password) - Mode.REGISTER -> container.api.register(email, current.password, current.name.trim()) + Mode.LOGIN -> container.api.login(identifier, current.password) + Mode.REGISTER -> + container.api.register(email, phone, current.password, current.name.trim()) } container.tokenStore.save(tokens.accessToken, tokens.refreshToken) _state.update { it.copy(loading = false) } diff --git a/android/app/src/main/java/com/galo/android/ui/profile/ProfileScreen.kt b/android/app/src/main/java/com/galo/android/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..d48505d --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/profile/ProfileScreen.kt @@ -0,0 +1,246 @@ +package com.galo.android.ui.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.galo.android.ui.GaloViewModelFactory + +const val TAG_PROFILE_EMAIL = "profile_email" +const val TAG_PROFILE_PHONE = "profile_phone" +const val TAG_PROFILE_NAME = "profile_name" +const val TAG_PROFILE_SAVE_NAME = "profile_save_name" +const val TAG_PROFILE_NEW_EMAIL = "profile_new_email" +const val TAG_PROFILE_NEW_PHONE = "profile_new_phone" +const val TAG_PROFILE_ADD_ID = "profile_add_identifier" +const val TAG_PROFILE_CURRENT_PASSWORD = "profile_current_password" +const val TAG_PROFILE_NEW_PASSWORD = "profile_new_password" +const val TAG_PROFILE_CHANGE_PASSWORD = "profile_change_password" +const val TAG_PROFILE_DELETE_PASSWORD = "profile_delete_password" +const val TAG_PROFILE_DELETE = "profile_delete" + +@Composable +fun ProfileScreen( + factory: GaloViewModelFactory, + onLoggedOut: () -> Unit, + onBack: () -> Unit, +) { + val viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory) + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loggedOut.collect { onLoggedOut() } + } + + ProfileContent( + state = state, + onNameChange = viewModel::onNameChange, + onSaveName = viewModel::saveName, + onNewEmailChange = viewModel::onNewEmailChange, + onNewPhoneChange = viewModel::onNewPhoneChange, + onAddIdentifier = viewModel::addIdentifier, + onCurrentPasswordChange = viewModel::onCurrentPasswordChange, + onNewPasswordChange = viewModel::onNewPasswordChange, + onChangePassword = viewModel::changePassword, + onDeletePasswordChange = viewModel::onDeletePasswordChange, + onDeleteAccount = viewModel::deleteAccount, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileContent( + state: ProfileViewModel.UiState, + onNameChange: (String) -> Unit, + onSaveName: () -> Unit, + onNewEmailChange: (String) -> Unit, + onNewPhoneChange: (String) -> Unit, + onAddIdentifier: () -> Unit, + onCurrentPasswordChange: (String) -> Unit, + onNewPasswordChange: (String) -> Unit, + onChangePassword: () -> Unit, + onDeletePasswordChange: (String) -> Unit, + onDeleteAccount: () -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Профиль") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад") + } + }, + ) + }, + ) { padding -> + if (state.loading) { + Column( + modifier = Modifier.fillMaxSize().padding(padding), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + return@Scaffold + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Идентификаторы", style = MaterialTheme.typography.titleMedium) + Text( + "Email: ${state.email ?: "—"}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(TAG_PROFILE_EMAIL), + ) + Text( + "Телефон: ${state.phone ?: "—"}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(TAG_PROFILE_PHONE), + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Имя", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = state.name, + onValueChange = onNameChange, + label = { Text("Отображаемое имя") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_NAME), + ) + Button( + onClick = onSaveName, + modifier = Modifier.testTag(TAG_PROFILE_SAVE_NAME), + ) { + Text("Сохранить") + } + } + + if (state.email.isNullOrBlank() || state.phone.isNullOrBlank()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Добавить идентификатор", style = MaterialTheme.typography.titleMedium) + if (state.email.isNullOrBlank()) { + OutlinedTextField( + value = state.newEmail, + onValueChange = onNewEmailChange, + label = { Text("Email") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_NEW_EMAIL), + ) + } + if (state.phone.isNullOrBlank()) { + OutlinedTextField( + value = state.newPhone, + onValueChange = onNewPhoneChange, + label = { Text("Телефон (E.164)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_NEW_PHONE), + ) + } + Button( + onClick = onAddIdentifier, + modifier = Modifier.testTag(TAG_PROFILE_ADD_ID), + ) { + Text("Добавить") + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Смена пароля", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = state.currentPassword, + onValueChange = onCurrentPasswordChange, + label = { Text("Текущий пароль") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_CURRENT_PASSWORD), + ) + OutlinedTextField( + value = state.newPassword, + onValueChange = onNewPasswordChange, + label = { Text("Новый пароль") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_NEW_PASSWORD), + ) + Button( + onClick = onChangePassword, + modifier = Modifier.testTag(TAG_PROFILE_CHANGE_PASSWORD), + ) { + Text("Сменить пароль") + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Удаление аккаунта", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + OutlinedTextField( + value = state.deletePassword, + onValueChange = onDeletePasswordChange, + label = { Text("Подтвердите пароль") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_DELETE_PASSWORD), + ) + OutlinedButton( + onClick = onDeleteAccount, + modifier = Modifier.testTag(TAG_PROFILE_DELETE), + ) { + Text("Удалить аккаунт") + } + } + + state.error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + state.notice?.let { + Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium) + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/profile/ProfileViewModel.kt b/android/app/src/main/java/com/galo/android/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..8c78048 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/profile/ProfileViewModel.kt @@ -0,0 +1,170 @@ +package com.galo.android.ui.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.galo.android.data.auth.TokenStore +import com.galo.android.data.network.ErrorMapper +import com.galo.android.data.network.ProfileApi +import com.galo.android.domain.model.ApiException +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Drives the profile screen. Password change and account deletion clear the + * local session and signal [loggedOut] so the caller returns to the auth screen. + */ +class ProfileViewModel( + private val api: ProfileApi, + private val tokenStore: TokenStore, +) : ViewModel() { + + data class UiState( + val loading: Boolean = true, + val name: String = "", + val email: String? = null, + val phone: String? = null, + val newEmail: String = "", + val newPhone: String = "", + val currentPassword: String = "", + val newPassword: String = "", + val deletePassword: String = "", + val error: String? = null, + val notice: String? = null, + ) + + private val _state = MutableStateFlow(UiState()) + val state = _state.asStateFlow() + + private val _loggedOut = MutableSharedFlow(extraBufferCapacity = 1) + val loggedOut = _loggedOut.asSharedFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null) } + try { + val user = api.me() + _state.update { + it.copy(loading = false, name = user.name, email = user.email, phone = user.phone) + } + } catch (e: ApiException) { + _state.update { it.copy(loading = false, error = ErrorMapper.userMessage(e.error)) } + } catch (e: Exception) { + _state.update { it.copy(loading = false, error = e.message ?: "Не удалось загрузить профиль") } + } + } + } + + fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null, notice = null) } + + fun onNewEmailChange(value: String) = + _state.update { it.copy(newEmail = value, error = null, notice = null) } + + fun onNewPhoneChange(value: String) = + _state.update { it.copy(newPhone = value, error = null, notice = null) } + + fun onCurrentPasswordChange(value: String) = + _state.update { it.copy(currentPassword = value, error = null) } + + fun onNewPasswordChange(value: String) = + _state.update { it.copy(newPassword = value, error = null) } + + fun onDeletePasswordChange(value: String) = + _state.update { it.copy(deletePassword = value, error = null) } + + fun saveName() { + val name = _state.value.name.trim() + if (name.isEmpty()) { + _state.update { it.copy(error = "Укажите имя") } + return + } + mutate(notice = "Имя сохранено") { api.updateProfile(name) } + } + + fun addIdentifier() { + val current = _state.value + val email = current.newEmail.trim() + val phone = current.newPhone.trim() + if (email.isEmpty() && phone.isEmpty()) { + _state.update { it.copy(error = "Укажите email или телефон") } + return + } + viewModelScope.launch { + _state.update { it.copy(error = null, notice = null) } + try { + val user = api.addIdentifier(email, phone) + _state.update { + it.copy(email = user.email, phone = user.phone, newEmail = "", newPhone = "", notice = "Идентификатор добавлен") + } + } catch (e: ApiException) { + _state.update { it.copy(error = ErrorMapper.userMessage(e.error)) } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "Не удалось добавить идентификатор") } + } + } + } + + fun changePassword() { + val current = _state.value + if (current.newPassword.length < 8) { + _state.update { it.copy(error = "Пароль должен быть не короче 8 символов") } + return + } + viewModelScope.launch { + _state.update { it.copy(error = null) } + try { + api.changePassword(current.currentPassword, current.newPassword) + logout() + } catch (e: ApiException) { + _state.update { it.copy(error = ErrorMapper.userMessage(e.error)) } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "Не удалось сменить пароль") } + } + } + } + + fun deleteAccount() { + val password = _state.value.deletePassword + if (password.isEmpty()) { + _state.update { it.copy(error = "Введите пароль") } + return + } + viewModelScope.launch { + _state.update { it.copy(error = null) } + try { + api.deleteAccount(password) + logout() + } catch (e: ApiException) { + _state.update { it.copy(error = ErrorMapper.userMessage(e.error)) } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "Не удалось удалить аккаунт") } + } + } + } + + private fun logout() { + tokenStore.clear() + _loggedOut.tryEmit(Unit) + } + + private inline fun mutate(notice: String, crossinline block: suspend () -> com.galo.android.data.network.dto.UserDto) { + viewModelScope.launch { + _state.update { it.copy(error = null, notice = null) } + try { + val user = block() + _state.update { it.copy(name = user.name, email = user.email, phone = user.phone, notice = notice) } + } catch (e: ApiException) { + _state.update { it.copy(error = ErrorMapper.userMessage(e.error)) } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "Ошибка сохранения") } + } + } + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt index 97908e9..66d1871 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt @@ -40,6 +40,7 @@ class AlbumsContentTest { state = AlbumsViewModel.UiState(albums = listOf(album("a1", "Summer"))), onOpenAlbum = {}, onOpenUploads = {}, + onOpenProfile = {}, onRefresh = {}, onLogout = {}, ) @@ -63,6 +64,7 @@ class AlbumsContentTest { ), onOpenAlbum = {}, onOpenUploads = {}, + onOpenProfile = {}, onRefresh = {}, onLogout = {}, ) @@ -79,6 +81,7 @@ class AlbumsContentTest { state = AlbumsViewModel.UiState(), onOpenAlbum = {}, onOpenUploads = {}, + onOpenProfile = {}, onRefresh = {}, onLogout = {}, ) diff --git a/android/app/src/test/java/com/galo/android/ui/auth/AuthContentTest.kt b/android/app/src/test/java/com/galo/android/ui/auth/AuthContentTest.kt new file mode 100644 index 0000000..d4331b8 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/auth/AuthContentTest.kt @@ -0,0 +1,52 @@ +package com.galo.android.ui.auth + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.galo.android.ui.theme.GaloTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w411dp-h2200dp") +class AuthContentTest { + + @get:Rule + val compose = createComposeRule() + + private fun render(state: AuthViewModel.UiState) { + compose.setContent { + GaloTheme(dynamicColor = false) { + AuthContent( + state = state, + onIdentifierChange = {}, + onEmailChange = {}, + onPhoneChange = {}, + onPasswordChange = {}, + onNameChange = {}, + onSubmit = {}, + onToggleMode = {}, + ) + } + } + } + + @Test + fun `login shows a single identifier field`() { + render(AuthViewModel.UiState(mode = AuthViewModel.Mode.LOGIN)) + compose.onNodeWithTag(TAG_AUTH_IDENTIFIER).assertIsDisplayed() + compose.onNodeWithTag(TAG_AUTH_PASSWORD).assertIsDisplayed() + } + + @Test + fun `register shows optional email and phone fields`() { + render(AuthViewModel.UiState(mode = AuthViewModel.Mode.REGISTER)) + compose.onNodeWithTag(TAG_AUTH_NAME).assertIsDisplayed() + compose.onNodeWithTag(TAG_AUTH_EMAIL).assertIsDisplayed() + compose.onNodeWithTag(TAG_AUTH_PHONE).assertIsDisplayed() + compose.onNodeWithTag(TAG_AUTH_SUBMIT).assertIsDisplayed() + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/profile/ProfileContentTest.kt b/android/app/src/test/java/com/galo/android/ui/profile/ProfileContentTest.kt new file mode 100644 index 0000000..e6464e8 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/profile/ProfileContentTest.kt @@ -0,0 +1,94 @@ +package com.galo.android.ui.profile + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w411dp-h2400dp") +class ProfileContentTest { + + @get:Rule + val compose = createComposeRule() + + private fun render( + state: ProfileViewModel.UiState, + onAddIdentifier: () -> Unit = {}, + onDeleteAccount: () -> Unit = {}, + onChangePassword: () -> Unit = {}, + ) { + compose.setContent { + GaloTheme(dynamicColor = false) { + ProfileContent( + state = state, + onNameChange = {}, + onSaveName = {}, + onNewEmailChange = {}, + onNewPhoneChange = {}, + onAddIdentifier = onAddIdentifier, + onCurrentPasswordChange = {}, + onNewPasswordChange = {}, + onChangePassword = onChangePassword, + onDeletePasswordChange = {}, + onDeleteAccount = onDeleteAccount, + onBack = {}, + ) + } + } + } + + @Test + fun `identifiers and forms are shown`() { + render( + ProfileViewModel.UiState( + loading = false, + name = "Tester", + email = "t@test.local", + phone = "+15550000001", + ), + ) + compose.onNodeWithText("Email: t@test.local").assertIsDisplayed() + compose.onNodeWithText("Телефон: +15550000001").assertIsDisplayed() + compose.onNodeWithTag(TAG_PROFILE_NAME).assertIsDisplayed() + compose.onNodeWithTag(TAG_PROFILE_CHANGE_PASSWORD).assertIsDisplayed() + compose.onNodeWithTag(TAG_PROFILE_DELETE).assertIsDisplayed() + } + + @Test + fun `missing identifier exposes add form`() { + var added = false + render( + ProfileViewModel.UiState( + loading = false, + name = "Tester", + email = "t@test.local", + phone = null, + ), + onAddIdentifier = { added = true }, + ) + compose.onNodeWithTag(TAG_PROFILE_NEW_PHONE).assertIsDisplayed() + compose.onNodeWithTag(TAG_PROFILE_ADD_ID).performClick() + assertTrue(added) + } + + @Test + fun `delete button triggers confirmation flow`() { + var deleted = false + render( + ProfileViewModel.UiState(loading = false, email = "t@test.local"), + onDeleteAccount = { deleted = true }, + ) + compose.onNodeWithTag(TAG_PROFILE_DELETE_PASSWORD).assertIsDisplayed() + compose.onNodeWithTag(TAG_PROFILE_DELETE).performClick() + assertTrue(deleted) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/profile/ProfileViewModelTest.kt b/android/app/src/test/java/com/galo/android/ui/profile/ProfileViewModelTest.kt new file mode 100644 index 0000000..ad84a31 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/profile/ProfileViewModelTest.kt @@ -0,0 +1,115 @@ +package com.galo.android.ui.profile + +import com.galo.android.data.auth.InMemoryTokenStore +import com.galo.android.data.network.ProfileApi +import com.galo.android.data.network.dto.UserDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ProfileViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private class FakeApi : ProfileApi { + var changed: Pair? = null + var deletedPassword: String? = null + + override suspend fun me(): UserDto = + UserDto(id = "u1", email = "a@test.local", phone = "+15550000001", name = "Tester") + + override suspend fun updateProfile(name: String): UserDto = + UserDto(id = "u1", email = "a@test.local", phone = "+15550000001", name = name) + + override suspend fun changePassword(currentPassword: String, newPassword: String) { + changed = currentPassword to newPassword + } + + override suspend fun addIdentifier(email: String, phone: String): UserDto = + UserDto( + id = "u1", + email = email.ifBlank { null }, + phone = phone.ifBlank { null }, + name = "Tester", + ) + + override suspend fun deleteAccount(password: String) { + deletedPassword = password + } + } + + @Test + fun `successful password change clears tokens`() { + val store = InMemoryTokenStore().apply { save("access", "refresh") } + val api = FakeApi() + val vm = ProfileViewModel(api, store) + + vm.onCurrentPasswordChange("oldpass12") + vm.onNewPasswordChange("newpass12") + vm.changePassword() + + assertEquals("oldpass12" to "newpass12", api.changed) + assertNull(store.accessToken()) + assertNull(store.refreshToken()) + } + + @Test + fun `successful deletion clears tokens`() { + val store = InMemoryTokenStore().apply { save("access", "refresh") } + val api = FakeApi() + val vm = ProfileViewModel(api, store) + + vm.onDeletePasswordChange("secret12") + vm.deleteAccount() + + assertEquals("secret12", api.deletedPassword) + assertNull(store.accessToken()) + } + + @Test + fun `weak password is rejected without clearing the session`() { + val store = InMemoryTokenStore().apply { save("access", "refresh") } + val vm = ProfileViewModel(FakeApi(), store) + + vm.onNewPasswordChange("short") + vm.changePassword() + + assertNotNull(store.accessToken()) + assertEquals("Пароль должен быть не короче 8 символов", vm.state.value.error) + } + + @Test + fun `name change updates the loaded profile`() { + val store = InMemoryTokenStore() + val vm = ProfileViewModel(FakeApi(), store) + + vm.onNameChange("New Name") + vm.saveName() + + assertEquals("New Name", vm.state.value.name) + assertTrue(vm.state.value.notice != null) + } +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 3ce4885..a8f152b 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -133,6 +133,10 @@ func (s *Server) Router() *gin.Engine { authed.Use(s.auth.Middleware()) authed.GET("/auth/me", authHandler.Me) + authed.PATCH("/auth/me", authHandler.UpdateMe) + authed.POST("/auth/me/password", authHandler.ChangePassword) + authed.POST("/auth/me/identifiers", authHandler.AddIdentifier) + authed.DELETE("/auth/me", authHandler.DeleteMe) authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences) authed.GET("/sse/albums/:id", s.albumSSE) authed.GET("/sse/me", s.meSSE) diff --git a/backend/internal/auth/handlers.go b/backend/internal/auth/handlers.go index 410704f..19bac96 100644 --- a/backend/internal/auth/handlers.go +++ b/backend/internal/auth/handlers.go @@ -18,7 +18,8 @@ type Handler struct { func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } type registerRequest struct { - Email string `json:"email" binding:"required"` + Email string `json:"email"` + Phone string `json:"phone"` Password string `json:"password" binding:"required"` Name string `json:"name" binding:"required"` } @@ -26,34 +27,34 @@ type registerRequest struct { func (h *Handler) Register(c *gin.Context) { var req registerRequest if err := c.ShouldBindJSON(&req); err != nil { - httpx.RespondError(c, httpx.BadRequest("invalid_request", "email, password and name are required")) + httpx.RespondError(c, httpx.BadRequest("invalid_request", "name and password are required")) return } - u, pair, err := h.svc.Register(c.Request.Context(), req.Email, req.Password, req.Name) + u, pair, err := h.svc.Register(c.Request.Context(), req.Email, req.Phone, req.Password, req.Name) if err != nil { httpx.RespondError(c, err) return } - c.JSON(http.StatusCreated, gin.H{"user": u.Public(), "tokens": pair}) + c.JSON(http.StatusCreated, gin.H{"user": u.Self(), "tokens": pair}) } type loginRequest struct { - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required"` + Identifier string `json:"identifier" binding:"required"` + Password string `json:"password" binding:"required"` } func (h *Handler) Login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { - httpx.RespondError(c, httpx.BadRequest("invalid_request", "email and password are required")) + httpx.RespondError(c, httpx.BadRequest("invalid_request", "identifier and password are required")) return } - u, pair, err := h.svc.Login(c.Request.Context(), req.Email, req.Password) + u, pair, err := h.svc.Login(c.Request.Context(), req.Identifier, req.Password) if err != nil { httpx.RespondError(c, err) return } - c.JSON(http.StatusOK, gin.H{"user": u.Public(), "tokens": pair}) + c.JSON(http.StatusOK, gin.H{"user": u.Self(), "tokens": pair}) } type refreshRequest struct { @@ -96,7 +97,83 @@ func (h *Handler) Me(c *gin.Context) { if prefs == nil { prefs = map[string]any{} } - c.JSON(http.StatusOK, gin.H{"user": u.Public(), "preferences": prefs}) + c.JSON(http.StatusOK, gin.H{"user": u.Self(), "preferences": prefs}) +} + +type updateMeRequest struct { + Name string `json:"name"` +} + +// UpdateMe changes the display name of the authenticated user. +func (h *Handler) UpdateMe(c *gin.Context) { + var req updateMeRequest + if err := c.ShouldBindJSON(&req); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "name is required")) + return + } + u, err := h.svc.UpdateName(c.Request.Context(), UserID(c), req.Name) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"user": u.Self()}) +} + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` +} + +// ChangePassword verifies the current password and revokes all refresh tokens. +func (h *Handler) ChangePassword(c *gin.Context) { + var req changePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "current_password and new_password are required")) + return + } + if err := h.svc.ChangePassword(c.Request.Context(), UserID(c), req.CurrentPassword, req.NewPassword); err != nil { + httpx.RespondError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +type addIdentifierRequest struct { + Email string `json:"email"` + Phone string `json:"phone"` +} + +// AddIdentifier adds the one missing login identifier. +func (h *Handler) AddIdentifier(c *gin.Context) { + var req addIdentifierRequest + if err := c.ShouldBindJSON(&req); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "email or phone is required")) + return + } + u, err := h.svc.AddIdentifier(c.Request.Context(), UserID(c), req.Email, req.Phone) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"user": u.Self()}) +} + +type deleteMeRequest struct { + Password string `json:"password" binding:"required"` +} + +// DeleteMe deletes the authenticated account and all cascading content. +func (h *Handler) DeleteMe(c *gin.Context) { + var req deleteMeRequest + if err := c.ShouldBindJSON(&req); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "password is required")) + return + } + if err := h.svc.DeleteAccount(c.Request.Context(), UserID(c), req.Password); err != nil { + httpx.RespondError(c, err) + return + } + c.Status(http.StatusNoContent) } const ( diff --git a/backend/internal/auth/handlers_test.go b/backend/internal/auth/handlers_test.go new file mode 100644 index 0000000..33cc075 --- /dev/null +++ b/backend/internal/auth/handlers_test.go @@ -0,0 +1,215 @@ +package auth + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "galo/internal/models" +) + +type authResponse struct { + User models.SelfUser `json:"user"` + Tokens *TokenPair `json:"tokens"` +} + +func newTestRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + h := NewHandler(newTestService(t)) + r.POST("/auth/register", h.Register) + r.POST("/auth/login", h.Login) + authed := r.Group("") + authed.Use(h.svc.Middleware()) + authed.GET("/auth/me", h.Me) + authed.PATCH("/auth/me", h.UpdateMe) + authed.POST("/auth/me/password", h.ChangePassword) + authed.POST("/auth/me/identifiers", h.AddIdentifier) + authed.DELETE("/auth/me", h.DeleteMe) + return r +} + +func doJSON(r *gin.Engine, method, path, token string, body any) *httptest.ResponseRecorder { + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } else { + buf.WriteString("{}") + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec +} + +func decodeAuth(t *testing.T, rec *httptest.ResponseRecorder) authResponse { + t.Helper() + var out authResponse + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + return out +} + +func TestRegisterHandlerPhoneOnly(t *testing.T) { + r := newTestRouter(t) + rec := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{ + "phone": "+15550000021", "password": "password123", "name": "Phone", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + out := decodeAuth(t, rec) + if out.User.Phone != "+15550000021" || out.User.Email != "" { + t.Fatalf("user = %+v", out.User) + } + if out.Tokens == nil || out.Tokens.AccessToken == "" { + t.Fatalf("no tokens") + } +} + +func TestRegisterHandlerRequiresIdentifier(t *testing.T) { + r := newTestRouter(t) + rec := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{ + "password": "password123", "name": "NoId", + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } +} + +func TestLoginHandlerByIdentifier(t *testing.T) { + r := newTestRouter(t) + reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{ + "email": "handler@test.local", "password": "password123", "name": "H", + }) + if reg.Code != http.StatusCreated { + t.Fatalf("register status = %d", reg.Code) + } + + rec := doJSON(r, http.MethodPost, "/auth/login", "", map[string]string{ + "identifier": "Handler@Test.Local", "password": "password123", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d body = %s", rec.Code, rec.Body.String()) + } + if out := decodeAuth(t, rec); out.Tokens == nil { + t.Fatalf("no tokens") + } +} + +func TestMeHandlerExposesPhoneToOwner(t *testing.T) { + r := newTestRouter(t) + reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{ + "email": "me@test.local", "phone": "+15550000022", "password": "password123", "name": "Me", + }) + out := decodeAuth(t, reg) + + rec := doJSON(r, http.MethodGet, "/auth/me", out.Tokens.AccessToken, nil) + if rec.Code != http.StatusOK { + t.Fatalf("me status = %d body = %s", rec.Code, rec.Body.String()) + } + var body struct { + User models.SelfUser `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.User.Phone != "+15550000022" { + t.Fatalf("phone not exposed to owner: %s", rec.Body.String()) + } +} + +func TestUpdateMeHandler(t *testing.T) { + r := newTestRouter(t) + reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{ + "email": "upd@test.local", "password": "password123", "name": "Old", + }) + token := decodeAuth(t, reg).Tokens.AccessToken + + if rec := doJSON(r, http.MethodPatch, "/auth/me", token, map[string]string{"name": " "}); rec.Code != http.StatusBadRequest { + t.Fatalf("empty name status = %d body = %s", rec.Code, rec.Body.String()) + } + + rec := doJSON(r, http.MethodPatch, "/auth/me", token, map[string]string{"name": "New"}) + if rec.Code != http.StatusOK { + t.Fatalf("update status = %d body = %s", rec.Code, rec.Body.String()) + } + var body struct { + User models.SelfUser `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body.User.Name != "New" { + t.Fatalf("name not updated: %s", rec.Body.String()) + } +} + +func TestChangePasswordHandler(t *testing.T) { + r := newTestRouter(t) + reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{ + "email": "cp@test.local", "password": "password123", "name": "CP", + }) + out := decodeAuth(t, reg) + + if rec := doJSON(r, http.MethodPost, "/auth/me/password", out.Tokens.AccessToken, map[string]string{ + "current_password": "wrong", "new_password": "newpassword", + }); rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong current status = %d", rec.Code) + } + if rec := doJSON(r, http.MethodPost, "/auth/me/password", out.Tokens.AccessToken, map[string]string{ + "current_password": "password123", "new_password": "newpassword", + }); rec.Code != http.StatusNoContent { + t.Fatalf("change status = %d body = %s", rec.Code, rec.Body.String()) + } +} + +func TestAddIdentifierHandler(t *testing.T) { + r := newTestRouter(t) + reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{ + "email": "addh@test.local", "password": "password123", "name": "Add", + }) + token := decodeAuth(t, reg).Tokens.AccessToken + + if rec := doJSON(r, http.MethodPost, "/auth/me/identifiers", token, map[string]string{"email": "x@test.local"}); rec.Code != http.StatusBadRequest { + t.Fatalf("re-set email status = %d", rec.Code) + } + + rec := doJSON(r, http.MethodPost, "/auth/me/identifiers", token, map[string]string{"phone": "+15550000023"}) + if rec.Code != http.StatusOK { + t.Fatalf("add status = %d body = %s", rec.Code, rec.Body.String()) + } + var body struct { + User models.SelfUser `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body.User.Phone != "+15550000023" { + t.Fatalf("phone not added: %s", rec.Body.String()) + } +} + +func TestDeleteMeHandler(t *testing.T) { + r := newTestRouter(t) + reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{ + "email": "delh@test.local", "password": "password123", "name": "Del", + }) + token := decodeAuth(t, reg).Tokens.AccessToken + + if rec := doJSON(r, http.MethodDelete, "/auth/me", token, map[string]string{"password": "wrong"}); rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong password status = %d", rec.Code) + } + if rec := doJSON(r, http.MethodDelete, "/auth/me", token, map[string]string{"password": "password123"}); rec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d body = %s", rec.Code, rec.Body.String()) + } + if rec := doJSON(r, http.MethodGet, "/auth/me", token, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("me after delete status = %d", rec.Code) + } +} diff --git a/backend/internal/auth/identifiers.go b/backend/internal/auth/identifiers.go new file mode 100644 index 0000000..ee8f2c9 --- /dev/null +++ b/backend/internal/auth/identifiers.go @@ -0,0 +1,63 @@ +package auth + +import ( + "strings" + + "galo/internal/httpx" +) + +// Identifier kinds for login and registration. +const ( + IdentifierEmail = "email" + IdentifierPhone = "phone" +) + +// NormalizePhone validates and canonicalises a phone number to E.164: a leading +// plus followed by 8–15 digits. It is the single normalisation point used by +// registration, identifier addition and login, so lookups compare canonical +// values only. +func NormalizePhone(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", httpx.BadRequest("invalid_phone", "phone is required") + } + if !strings.HasPrefix(s, "+") { + return "", httpx.BadRequest("invalid_phone", "phone must be in E.164 format with a leading +") + } + digits := s[1:] + if len(digits) < 8 || len(digits) > 15 { + return "", httpx.BadRequest("invalid_phone", "phone must contain between 8 and 15 digits") + } + for _, r := range digits { + if r < '0' || r > '9' { + return "", httpx.BadRequest("invalid_phone", "phone may contain digits only after the leading +") + } + } + return s, nil +} + +func validEmail(s string) bool { + i := strings.Index(s, "@") + return i > 0 && i < len(s)-1 && !strings.ContainsAny(s, " \t\r\n") +} + +// NormalizeIdentifier classifies a login identifier as email (contains `@`) or +// phone, returning the canonical value for lookups. +func NormalizeIdentifier(raw string) (kind, value string, err error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", "", httpx.BadRequest("invalid_identifier", "identifier is required") + } + if strings.Contains(s, "@") { + v := strings.ToLower(s) + if !validEmail(v) { + return "", "", httpx.BadRequest("invalid_identifier", "valid email is required") + } + return IdentifierEmail, v, nil + } + v, err := NormalizePhone(s) + if err != nil { + return "", "", err + } + return IdentifierPhone, v, nil +} diff --git a/backend/internal/auth/identifiers_test.go b/backend/internal/auth/identifiers_test.go new file mode 100644 index 0000000..cdcc9df --- /dev/null +++ b/backend/internal/auth/identifiers_test.go @@ -0,0 +1,63 @@ +package auth + +import "testing" + +func TestNormalizePhone(t *testing.T) { + valid := []string{ + "+15551234567", + "+442071234567", + "+12345678", + "+123456789012345", + } + for _, in := range valid { + got, err := NormalizePhone(in) + if err != nil { + t.Errorf("NormalizePhone(%q) error: %v", in, err) + } + if got != in { + t.Errorf("NormalizePhone(%q) = %q, want unchanged", in, got) + } + } + + invalid := []string{ + "", + "15551234567", + "+", + "+1234567", + "+1234567890123456", + "+1555abc4567", + "+1-555-123-4567", + "+1555 1234567", + } + for _, in := range invalid { + if _, err := NormalizePhone(in); err == nil { + t.Errorf("NormalizePhone(%q) accepted an invalid number", in) + } + } + + if got, err := NormalizePhone(" +15551234567 "); err != nil || got != "+15551234567" { + t.Errorf("NormalizePhone trimmed = %q err=%v", got, err) + } +} + +func TestNormalizeIdentifier(t *testing.T) { + kind, value, err := NormalizeIdentifier(" User@Test.Local ") + if err != nil || kind != IdentifierEmail || value != "user@test.local" { + t.Fatalf("email identifier = %q/%q err=%v", kind, value, err) + } + + kind, value, err = NormalizeIdentifier("+15551234567") + if err != nil || kind != IdentifierPhone || value != "+15551234567" { + t.Fatalf("phone identifier = %q/%q err=%v", kind, value, err) + } + + if _, _, err := NormalizeIdentifier(""); err == nil { + t.Errorf("empty identifier accepted") + } + if _, _, err := NormalizeIdentifier("no-at-sign"); err == nil { + t.Errorf("non-phone identifier accepted") + } + if _, _, err := NormalizeIdentifier("@bad"); err == nil { + t.Errorf("malformed email accepted") + } +} diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go index 5a5820a..27cb371 100644 --- a/backend/internal/auth/repository.go +++ b/backend/internal/auth/repository.go @@ -10,7 +10,14 @@ import ( type Repository interface { CreateUser(ctx context.Context, u *models.User) error GetUserByEmail(ctx context.Context, email string) (*models.User, error) + GetUserByPhone(ctx context.Context, phone string) (*models.User, error) GetUserByID(ctx context.Context, id string) (*models.User, error) + UpdateName(ctx context.Context, userID, name string) error + UpdatePasswordHash(ctx context.Context, userID, hash string) error + // AddIdentifier sets the missing email and/or phone of a user. The caller + // has already validated that only a missing identifier is provided. + AddIdentifier(ctx context.Context, userID, email, phone string) error + DeleteUser(ctx context.Context, userID string) error UpdatePreferences(ctx context.Context, userID string, raw []byte) error CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index bcd808d..befc5c2 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -26,23 +26,38 @@ func NewService(store Repository, tm *TokenManager) *Service { return &Service{store: store, tm: tm} } -func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, *TokenPair, error) { - email = strings.TrimSpace(strings.ToLower(email)) +// Register creates an account with at least one of email/phone. A missing +// identifier is stored as NULL so phone-only accounts are possible. +func (s *Service) Register(ctx context.Context, email, phone, password, name string) (*models.User, *TokenPair, error) { name = strings.TrimSpace(name) - if email == "" || !strings.Contains(email, "@") { - return nil, nil, httpx.BadRequest("invalid_email", "valid email is required") + if name == "" { + return nil, nil, httpx.BadRequest("invalid_name", "name is required") } if len(password) < 8 { return nil, nil, httpx.BadRequest("weak_password", "password must be at least 8 characters") } - if name == "" { - return nil, nil, httpx.BadRequest("invalid_name", "name is required") + + normEmail, normPhone, err := s.normalizeIdentifiers(email, phone) + if err != nil { + return nil, nil, err + } + if normEmail == "" && normPhone == "" { + return nil, nil, httpx.BadRequest("identifier_required", "email or phone is required") } - if _, err := s.store.GetUserByEmail(ctx, email); err == nil { - return nil, nil, httpx.Conflict("email_taken", "email is already registered") - } else if !errors.Is(err, ErrNotFound) { - return nil, nil, err + if normEmail != "" { + if _, err := s.store.GetUserByEmail(ctx, normEmail); err == nil { + return nil, nil, httpx.Conflict("email_taken", "email is already registered") + } else if !errors.Is(err, ErrNotFound) { + return nil, nil, err + } + } + if normPhone != "" { + if _, err := s.store.GetUserByPhone(ctx, normPhone); err == nil { + return nil, nil, httpx.Conflict("phone_taken", "phone is already registered") + } else if !errors.Is(err, ErrNotFound) { + return nil, nil, err + } } hash, err := HashPassword(password) @@ -51,13 +66,14 @@ func (s *Service) Register(ctx context.Context, email, password, name string) (* } u := &models.User{ ID: NewID(), - Email: email, + Email: normEmail, + Phone: normPhone, PasswordHash: hash, Name: name, CreatedAt: time.Now(), } if err := s.store.CreateUser(ctx, u); err != nil { - return nil, nil, err + return nil, nil, mapIdentifierConflict(err) } pair, err := s.issuePair(ctx, u) @@ -67,17 +83,27 @@ func (s *Service) Register(ctx context.Context, email, password, name string) (* return u, pair, nil } -func (s *Service) Login(ctx context.Context, email, password string) (*models.User, *TokenPair, error) { - email = strings.TrimSpace(strings.ToLower(email)) - u, err := s.store.GetUserByEmail(ctx, email) +// Login authenticates by a single identifier — email or E.164 phone — and never +// reveals which part of the credentials was wrong. +func (s *Service) Login(ctx context.Context, identifier, password string) (*models.User, *TokenPair, error) { + kind, value, err := NormalizeIdentifier(identifier) + if err != nil { + return nil, nil, invalidCredentials() + } + var u *models.User + if kind == IdentifierEmail { + u, err = s.store.GetUserByEmail(ctx, value) + } else { + u, err = s.store.GetUserByPhone(ctx, value) + } if err != nil { if errors.Is(err, ErrNotFound) { - return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password") + return nil, nil, invalidCredentials() } return nil, nil, err } if !CheckPassword(u.PasswordHash, password) { - return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password") + return nil, nil, invalidCredentials() } pair, err := s.issuePair(ctx, u) if err != nil { @@ -120,6 +146,148 @@ func (s *Service) Logout(ctx context.Context, refreshToken string) error { return s.store.RevokeRefreshToken(ctx, t.ID) } +// UpdateName changes the display name, rejecting an empty one. +func (s *Service) UpdateName(ctx context.Context, userID, name string) (*models.User, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, httpx.BadRequest("invalid_name", "name is required") + } + if err := s.store.UpdateName(ctx, userID, name); err != nil { + return nil, userErr(err) + } + return s.store.GetUserByID(ctx, userID) +} + +// ChangePassword verifies the current password and revokes every refresh token +// so all sessions must log in again. +func (s *Service) ChangePassword(ctx context.Context, userID, current, newPassword string) error { + if len(newPassword) < 8 { + return httpx.BadRequest("weak_password", "password must be at least 8 characters") + } + u, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return userErr(err) + } + if !CheckPassword(u.PasswordHash, current) { + return httpx.Unauthorized("current_password_incorrect", "current password is incorrect") + } + hash, err := HashPassword(newPassword) + if err != nil { + return err + } + if err := s.store.UpdatePasswordHash(ctx, userID, hash); err != nil { + return userErr(err) + } + return s.store.RevokeAllUserTokens(ctx, userID) +} + +// AddIdentifier adds the one missing login identifier. Rejecting an attempt to +// re-set an existing one or to provide both at once keeps identifiers immutable. +func (s *Service) AddIdentifier(ctx context.Context, userID, email, phone string) (*models.User, error) { + u, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return nil, userErr(err) + } + normEmail, normPhone, err := s.normalizeIdentifiers(email, phone) + if err != nil { + return nil, err + } + if normEmail == "" && normPhone == "" { + return nil, httpx.BadRequest("identifier_required", "email or phone is required") + } + if normEmail != "" && normPhone != "" { + return nil, httpx.BadRequest("invalid_request", "provide only one identifier at a time") + } + if u.Email != "" && u.Phone != "" { + return nil, httpx.BadRequest("identifiers_complete", "both identifiers are already set") + } + if normEmail != "" { + if u.Email != "" { + return nil, httpx.BadRequest("email_already_set", "email can no longer be changed") + } + if _, err := s.store.GetUserByEmail(ctx, normEmail); err == nil { + return nil, httpx.Conflict("email_taken", "email is already registered") + } else if !errors.Is(err, ErrNotFound) { + return nil, err + } + } + if normPhone != "" { + if u.Phone != "" { + return nil, httpx.BadRequest("phone_already_set", "phone can no longer be changed") + } + if _, err := s.store.GetUserByPhone(ctx, normPhone); err == nil { + return nil, httpx.Conflict("phone_taken", "phone is already registered") + } else if !errors.Is(err, ErrNotFound) { + return nil, err + } + } + if err := s.store.AddIdentifier(ctx, userID, normEmail, normPhone); err != nil { + return nil, mapIdentifierConflict(err) + } + return s.store.GetUserByID(ctx, userID) +} + +// DeleteAccount removes the account and everything that cascades from it after +// confirming the password. It is immediate and irreversible. +func (s *Service) DeleteAccount(ctx context.Context, userID, password string) error { + u, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return userErr(err) + } + if !CheckPassword(u.PasswordHash, password) { + return httpx.Unauthorized("current_password_incorrect", "password is incorrect") + } + if err := s.store.DeleteUser(ctx, userID); err != nil { + return userErr(err) + } + return nil +} + +// normalizeIdentifiers trims and canonicalises the provided email/phone. Empty +// fields stay empty; supplied ones must be valid. +func (s *Service) normalizeIdentifiers(email, phone string) (string, string, error) { + var normEmail, normPhone string + if e := strings.TrimSpace(email); e != "" { + e = strings.ToLower(e) + if !validEmail(e) { + return "", "", httpx.BadRequest("invalid_email", "valid email is required") + } + normEmail = e + } + if p := strings.TrimSpace(phone); p != "" { + np, err := NormalizePhone(p) + if err != nil { + return "", "", err + } + normPhone = np + } + return normEmail, normPhone, nil +} + +func invalidCredentials() error { + return httpx.Unauthorized("invalid_credentials", "invalid identifier or password") +} + +// userErr converts a missing user into an unauthorized/not-found style error +// appropriate for authenticated profile operations. +func userErr(err error) error { + if errors.Is(err, ErrNotFound) { + return httpx.Unauthorized("unauthorized", "user not found") + } + return err +} + +func mapIdentifierConflict(err error) error { + switch { + case errors.Is(err, ErrEmailTaken): + return httpx.Conflict("email_taken", "email is already registered") + case errors.Is(err, ErrPhoneTaken): + return httpx.Conflict("phone_taken", "phone is already registered") + default: + return err + } +} + func (s *Service) issuePair(ctx context.Context, u *models.User) (*TokenPair, error) { access, err := s.tm.GenerateAccess(u.ID, u.Email) if err != nil { diff --git a/backend/internal/auth/service_test.go b/backend/internal/auth/service_test.go new file mode 100644 index 0000000..b029785 --- /dev/null +++ b/backend/internal/auth/service_test.go @@ -0,0 +1,239 @@ +package auth + +import ( + "context" + "errors" + "io/fs" + "path/filepath" + "testing" + "time" + + assets "galo" + "galo/internal/db" + "galo/internal/httpx" +) + +func newTestService(t *testing.T) *Service { + t.Helper() + migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite") + if err != nil { + t.Fatalf("migrations fs: %v", err) + } + path := filepath.Join(t.TempDir(), "auth.db") + if err := db.MigrateSQLite(path, migFS); err != nil { + t.Fatalf("migrate: %v", err) + } + database, err := db.OpenSQLite(path) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { database.Close() }) + return NewService(NewSQLiteStore(database), NewTokenManager("secret", 15*time.Minute, time.Hour)) +} + +func errCode(t *testing.T, err error) string { + t.Helper() + var appErr *httpx.AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %v", err) + } + return appErr.Code +} + +func TestRegisterByEmailOrPhone(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + u, pair, err := svc.Register(ctx, "User@Test.Local", "", "password123", "Tester") + if err != nil { + t.Fatalf("register email: %v", err) + } + if u.Email != "user@test.local" || u.Phone != "" { + t.Fatalf("email registration stored %+v", u) + } + if pair == nil || pair.AccessToken == "" || pair.RefreshToken == "" { + t.Fatalf("no tokens issued") + } + + p, _, err := svc.Register(ctx, "", "+15550000001", "password123", "Phone") + if err != nil { + t.Fatalf("register phone: %v", err) + } + if p.Email != "" || p.Phone != "+15550000001" { + t.Fatalf("phone registration stored %+v", p) + } + + both, _, err := svc.Register(ctx, "both@test.local", "+15550000002", "password123", "Both") + if err != nil { + t.Fatalf("register both: %v", err) + } + if both.Email != "both@test.local" || both.Phone != "+15550000002" { + t.Fatalf("both registration stored %+v", both) + } +} + +func TestRegisterValidation(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + if _, _, err := svc.Register(ctx, "", "", "password123", "N"); errCode(t, err) != "identifier_required" { + t.Fatalf("no identifier code = %v", err) + } + if _, _, err := svc.Register(ctx, "a@test.local", "", "short", "N"); errCode(t, err) != "weak_password" { + t.Fatalf("weak password code = %v", err) + } + if _, _, err := svc.Register(ctx, "a@test.local", "", "password123", ""); errCode(t, err) != "invalid_name" { + t.Fatalf("empty name code = %v", err) + } + if _, _, err := svc.Register(ctx, "not-an-email", "", "password123", "N"); errCode(t, err) != "invalid_email" { + t.Fatalf("invalid email code = %v", err) + } + if _, _, err := svc.Register(ctx, "", "5550000", "password123", "N"); errCode(t, err) != "invalid_phone" { + t.Fatalf("invalid phone code = %v", err) + } +} + +func TestRegisterConflicts(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + if _, _, err := svc.Register(ctx, "dup@test.local", "", "password123", "A"); err != nil { + t.Fatalf("first register: %v", err) + } + if _, _, err := svc.Register(ctx, "Dup@Test.Local", "", "password123", "B"); errCode(t, err) != "email_taken" { + t.Fatalf("duplicate email code = %v", err) + } + if _, _, err := svc.Register(ctx, "", "+15550000009", "password123", "C"); err != nil { + t.Fatalf("phone register: %v", err) + } + if _, _, err := svc.Register(ctx, "", "+15550000009", "password123", "D"); errCode(t, err) != "phone_taken" { + t.Fatalf("duplicate phone code = %v", err) + } +} + +func TestLoginByIdentifier(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + if _, _, err := svc.Register(ctx, "login@test.local", "+15550000010", "password123", "Login"); err != nil { + t.Fatalf("register: %v", err) + } + + if _, pair, err := svc.Login(ctx, "Login@Test.Local", "password123"); err != nil || pair == nil { + t.Fatalf("login by email: %v", err) + } + if _, pair, err := svc.Login(ctx, "+15550000010", "password123"); err != nil || pair == nil { + t.Fatalf("login by phone: %v", err) + } + + _, _, errMissing := svc.Login(ctx, "nobody@test.local", "password123") + _, _, errWrong := svc.Login(ctx, "login@test.local", "wrong-password") + if errCode(t, errMissing) != "invalid_credentials" { + t.Fatalf("missing user code = %v", errMissing) + } + if errCode(t, errWrong) != "invalid_credentials" { + t.Fatalf("wrong password code = %v", errWrong) + } + if errMissing.Error() != errWrong.Error() { + t.Fatalf("errors distinguishable: %q vs %q", errMissing, errWrong) + } +} + +func TestUpdateName(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + u, _, err := svc.Register(ctx, "name@test.local", "", "password123", "Old") + if err != nil { + t.Fatalf("register: %v", err) + } + if _, err := svc.UpdateName(ctx, u.ID, " "); errCode(t, err) != "invalid_name" { + t.Fatalf("empty name code = %v", err) + } + got, err := svc.UpdateName(ctx, u.ID, " New Name ") + if err != nil || got.Name != "New Name" { + t.Fatalf("update name = %+v err=%v", got, err) + } +} + +func TestChangePasswordRevokesTokens(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + u, pair, err := svc.Register(ctx, "pass@test.local", "", "password123", "Pass") + if err != nil { + t.Fatalf("register: %v", err) + } + if err := svc.ChangePassword(ctx, u.ID, "wrong", "newpassword"); errCode(t, err) != "current_password_incorrect" { + t.Fatalf("wrong current password code = %v", err) + } + if err := svc.ChangePassword(ctx, u.ID, "password123", "short"); errCode(t, err) != "weak_password" { + t.Fatalf("weak new password code = %v", err) + } + if err := svc.ChangePassword(ctx, u.ID, "password123", "newpassword"); err != nil { + t.Fatalf("change password: %v", err) + } + if _, _, err := svc.Login(ctx, "pass@test.local", "newpassword"); err != nil { + t.Fatalf("login with new password: %v", err) + } + if _, _, err := svc.Login(ctx, "pass@test.local", "password123"); errCode(t, err) != "invalid_credentials" { + t.Fatalf("old password still works: %v", err) + } + if _, err := svc.Refresh(ctx, pair.RefreshToken); errCode(t, err) != "invalid_refresh_token" { + t.Fatalf("refresh token not revoked: %v", err) + } +} + +func TestAddIdentifier(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + u, _, err := svc.Register(ctx, "add@test.local", "", "password123", "Add") + if err != nil { + t.Fatalf("register: %v", err) + } + if _, err := svc.AddIdentifier(ctx, u.ID, "other@test.local", ""); errCode(t, err) != "email_already_set" { + t.Fatalf("re-set email code = %v", err) + } + if _, err := svc.AddIdentifier(ctx, u.ID, "", ""); errCode(t, err) != "identifier_required" { + t.Fatalf("no identifier code = %v", err) + } + if _, err := svc.AddIdentifier(ctx, u.ID, "x@test.local", "+15550000011"); errCode(t, err) != "invalid_request" { + t.Fatalf("both at once code = %v", err) + } + + got, err := svc.AddIdentifier(ctx, u.ID, "", "+15550000011") + if err != nil || got.Phone != "+15550000011" { + t.Fatalf("add phone = %+v err=%v", got, err) + } + if _, err := svc.AddIdentifier(ctx, u.ID, "later@test.local", ""); errCode(t, err) != "identifiers_complete" { + t.Fatalf("both set code = %v", err) + } + + other, _, _ := svc.Register(ctx, "owner@test.local", "", "password123", "Owner") + if _, err := svc.AddIdentifier(ctx, other.ID, "", "+15550000011"); errCode(t, err) != "phone_taken" { + t.Fatalf("phone conflict code = %v", err) + } +} + +func TestDeleteAccount(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + u, _, err := svc.Register(ctx, "delete@test.local", "", "password123", "Delete") + if err != nil { + t.Fatalf("register: %v", err) + } + if err := svc.DeleteAccount(ctx, u.ID, "wrong"); errCode(t, err) != "current_password_incorrect" { + t.Fatalf("wrong password code = %v", err) + } + if err := svc.DeleteAccount(ctx, u.ID, "password123"); err != nil { + t.Fatalf("delete account: %v", err) + } + if _, _, err := svc.Login(ctx, "delete@test.local", "password123"); errCode(t, err) != "invalid_credentials" { + t.Fatalf("deleted account can still log in: %v", err) + } + if _, err := svc.store.GetUserByID(ctx, u.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("user row survived deletion: %v", err) + } +} diff --git a/backend/internal/auth/sqlite_store.go b/backend/internal/auth/sqlite_store.go index b0022bb..a04b45d 100644 --- a/backend/internal/auth/sqlite_store.go +++ b/backend/internal/auth/sqlite_store.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "strings" "time" "galo/internal/models" @@ -18,32 +19,36 @@ type SQLiteStore struct { func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} } +const sqliteUserColumns = `id, COALESCE(email, ''), COALESCE(phone, ''), password_hash, name, + COALESCE(avatar_url, ''), COALESCE(preferences, '{}'), created_at` + func (s *SQLiteStore) CreateUser(ctx context.Context, u *models.User) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO users (id, email, password_hash, name, avatar_url) - VALUES ($1, $2, $3, $4, $5)`, - u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL)) - return err + `INSERT INTO users (id, email, phone, password_hash, name, avatar_url) + VALUES ($1, $2, $3, $4, $5, $6)`, + u.ID, nullIfEmpty(u.Email), nullIfEmpty(u.Phone), u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL)) + return mapUniqueViolationSQLite(err) } func (s *SQLiteStore) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { return s.scanUser(s.db.QueryRowContext(ctx, - `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), - COALESCE(preferences, '{}'), created_at - FROM users WHERE lower(email) = lower($1)`, email)) + `SELECT `+sqliteUserColumns+` FROM users WHERE lower(email) = lower($1)`, email)) +} + +func (s *SQLiteStore) GetUserByPhone(ctx context.Context, phone string) (*models.User, error) { + return s.scanUser(s.db.QueryRowContext(ctx, + `SELECT `+sqliteUserColumns+` FROM users WHERE phone = $1`, phone)) } func (s *SQLiteStore) GetUserByID(ctx context.Context, id string) (*models.User, error) { return s.scanUser(s.db.QueryRowContext(ctx, - `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), - COALESCE(preferences, '{}'), created_at - FROM users WHERE id = $1`, id)) + `SELECT `+sqliteUserColumns+` FROM users WHERE id = $1`, id)) } func (s *SQLiteStore) scanUser(row *sql.Row) (*models.User, error) { var u models.User var prefs, createdAt string - if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &createdAt); err != nil { + if err := row.Scan(&u.ID, &u.Email, &u.Phone, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &createdAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -56,9 +61,34 @@ func (s *SQLiteStore) scanUser(row *sql.Row) (*models.User, error) { return &u, nil } +func (s *SQLiteStore) UpdateName(ctx context.Context, userID, name string) error { + return s.requireRow(s.db.ExecContext(ctx, `UPDATE users SET name = $2 WHERE id = $1`, userID, name)) +} + +func (s *SQLiteStore) UpdatePasswordHash(ctx context.Context, userID, hash string) error { + return s.requireRow(s.db.ExecContext(ctx, `UPDATE users SET password_hash = $2 WHERE id = $1`, userID, hash)) +} + +func (s *SQLiteStore) AddIdentifier(ctx context.Context, userID, email, phone string) error { + err := s.requireRow(s.db.ExecContext(ctx, + `UPDATE users + SET email = COALESCE(NULLIF($2, ''), email), + phone = COALESCE(NULLIF($3, ''), phone) + WHERE id = $1`, + userID, email, phone)) + return mapUniqueViolationSQLite(err) +} + +func (s *SQLiteStore) DeleteUser(ctx context.Context, userID string) error { + return s.requireRow(s.db.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, userID)) +} + func (s *SQLiteStore) UpdatePreferences(ctx context.Context, userID string, raw []byte) error { - res, err := s.db.ExecContext(ctx, - `UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw)) + return s.requireRow(s.db.ExecContext(ctx, + `UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw))) +} + +func (s *SQLiteStore) requireRow(res sql.Result, err error) error { if err != nil { return err } @@ -109,3 +139,21 @@ func (s *SQLiteStore) RevokeAllUserTokens(ctx context.Context, userID string) er userID, sqliteutil.FormatTime(time.Now())) return err } + +// mapUniqueViolationSQLite converts a SQLite unique constraint violation into +// the domain sentinel so the service can return a conflict for the right field. +func mapUniqueViolationSQLite(err error) error { + if err == nil { + return nil + } + msg := err.Error() + if strings.Contains(msg, "UNIQUE constraint failed") { + if strings.Contains(msg, "users.phone") { + return ErrPhoneTaken + } + if strings.Contains(msg, "users.email") { + return ErrEmailTaken + } + } + return err +} diff --git a/backend/internal/auth/store.go b/backend/internal/auth/store.go index c82db6d..a19ea38 100644 --- a/backend/internal/auth/store.go +++ b/backend/internal/auth/store.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "strings" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "galo/internal/models" @@ -13,39 +15,51 @@ import ( var ErrNotFound = errors.New("not found") +// ErrEmailTaken and ErrPhoneTaken are returned when a unique constraint on the +// corresponding identifier is violated, so callers can map a race to the same +// conflict code as the pre-flight existence check. +var ( + ErrEmailTaken = errors.New("email taken") + ErrPhoneTaken = errors.New("phone taken") +) + type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } +const userColumns = `id, COALESCE(email, ''), COALESCE(phone, ''), password_hash, name, + COALESCE(avatar_url, ''), COALESCE(preferences, '{}'::jsonb)::text, created_at` + func (s *Store) CreateUser(ctx context.Context, u *models.User) error { _, err := s.pool.Exec(ctx, - `INSERT INTO users (id, email, password_hash, name, avatar_url) - VALUES ($1, $2, $3, $4, $5)`, - u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL), + `INSERT INTO users (id, email, phone, password_hash, name, avatar_url) + VALUES ($1, $2, $3, $4, $5, $6)`, + u.ID, nullIfEmpty(u.Email), nullIfEmpty(u.Phone), u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL), ) - return err + return mapUniqueViolation(err) } func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { return s.scanUser(s.pool.QueryRow(ctx, - `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), - COALESCE(preferences, '{}'::jsonb)::text, created_at - FROM users WHERE lower(email) = lower($1)`, email)) + `SELECT `+userColumns+` FROM users WHERE lower(email) = lower($1)`, email)) +} + +func (s *Store) GetUserByPhone(ctx context.Context, phone string) (*models.User, error) { + return s.scanUser(s.pool.QueryRow(ctx, + `SELECT `+userColumns+` FROM users WHERE phone = $1`, phone)) } func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) { return s.scanUser(s.pool.QueryRow(ctx, - `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), - COALESCE(preferences, '{}'::jsonb)::text, created_at - FROM users WHERE id = $1`, id)) + `SELECT `+userColumns+` FROM users WHERE id = $1`, id)) } func (s *Store) scanUser(row pgx.Row) (*models.User, error) { var u models.User var prefs string - if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &u.CreatedAt); err != nil { + if err := row.Scan(&u.ID, &u.Email, &u.Phone, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &u.CreatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -57,16 +71,38 @@ func (s *Store) scanUser(row pgx.Row) (*models.User, error) { return &u, nil } +func (s *Store) UpdateName(ctx context.Context, userID, name string) error { + tag, err := s.pool.Exec(ctx, `UPDATE users SET name = $2 WHERE id = $1`, userID, name) + return requireRow(tag, err) +} + +func (s *Store) UpdatePasswordHash(ctx context.Context, userID, hash string) error { + tag, err := s.pool.Exec(ctx, `UPDATE users SET password_hash = $2 WHERE id = $1`, userID, hash) + return requireRow(tag, err) +} + +func (s *Store) AddIdentifier(ctx context.Context, userID, email, phone string) error { + tag, err := s.pool.Exec(ctx, + `UPDATE users + SET email = COALESCE(NULLIF($2, ''), email), + phone = COALESCE(NULLIF($3, ''), phone) + WHERE id = $1`, + userID, email, phone) + if err != nil { + return mapUniqueViolation(err) + } + return requireRow(tag, nil) +} + +func (s *Store) DeleteUser(ctx context.Context, userID string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) + return requireRow(tag, err) +} + func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte) error { tag, err := s.pool.Exec(ctx, `UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw)) - if err != nil { - return err - } - if tag.RowsAffected() == 0 { - return ErrNotFound - } - return nil + return requireRow(tag, err) } func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error { @@ -111,3 +147,33 @@ func nullIfEmpty(s string) *string { } return &s } + +type commandTag interface { + RowsAffected() int64 +} + +func requireRow(tag commandTag, err error) error { + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// mapUniqueViolation converts a PostgreSQL unique constraint violation into the +// domain sentinel so the service can return a conflict for the right field. +func mapUniqueViolation(err error) error { + if err == nil { + return nil + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + if strings.Contains(pgErr.ConstraintName, "phone") { + return ErrPhoneTaken + } + return ErrEmailTaken + } + return err +} diff --git a/backend/internal/e2e/auth_flow_test.go b/backend/internal/e2e/auth_flow_test.go new file mode 100644 index 0000000..0d2b3d4 --- /dev/null +++ b/backend/internal/e2e/auth_flow_test.go @@ -0,0 +1,132 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + + "galo/internal/app" + "galo/internal/config" + "galo/internal/models" +) + +// TestAuthProfileFlowOverHTTP drives the whole account lifecycle through the +// real HTTP router: register phone-only, log in, add an email, change the +// password (revoking sessions), and delete the account. +func TestAuthProfileFlowOverHTTP(t *testing.T) { + st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) + defer closeDB() + + gin.SetMode(gin.TestMode) + srv := app.New(&config.Config{}, st.repos, st.db.PingContext, st.tm, st.storage, st.hub) + router := srv.Router() + + do := func(method, path, token string, body any) *httptest.ResponseRecorder { + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + + type tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + type authBody struct { + User models.SelfUser `json:"user"` + Tokens tokens `json:"tokens"` + } + + phone := "+15550007000" + // Register with a phone only — no email. + rec := do(http.MethodPost, "/api/auth/register", "", map[string]string{ + "phone": phone, "password": "password123", "name": "Phone Only", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("register status = %d body = %s", rec.Code, rec.Body.String()) + } + var registered authBody + if err := json.Unmarshal(rec.Body.Bytes(), ®istered); err != nil { + t.Fatalf("decode register: %v", err) + } + if registered.User.Phone != phone || registered.User.Email != "" { + t.Fatalf("registered user = %+v", registered.User) + } + + // Log in by phone. + rec = do(http.MethodPost, "/api/auth/login", "", map[string]string{ + "identifier": phone, "password": "password123", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d body = %s", rec.Code, rec.Body.String()) + } + var logged authBody + _ = json.Unmarshal(rec.Body.Bytes(), &logged) + access := logged.Tokens.AccessToken + + // Add the missing email identifier. + rec = do(http.MethodPost, "/api/auth/me/identifiers", access, map[string]string{ + "email": "phoneonly@test.local", + }) + if rec.Code != http.StatusOK { + t.Fatalf("add identifier status = %d body = %s", rec.Code, rec.Body.String()) + } + var added struct { + User models.SelfUser `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &added) + if added.User.Email != "phoneonly@test.local" { + t.Fatalf("email not added: %+v", added.User) + } + + // Change the password, which revokes every refresh token. + rec = do(http.MethodPost, "/api/auth/me/password", access, map[string]string{ + "current_password": "password123", "new_password": "newpassword", + }) + if rec.Code != http.StatusNoContent { + t.Fatalf("change password status = %d body = %s", rec.Code, rec.Body.String()) + } + rec = do(http.MethodPost, "/api/auth/refresh", "", map[string]string{ + "refresh_token": logged.Tokens.RefreshToken, + }) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("refresh after password change status = %d, want 401", rec.Code) + } + + // The new password works; login by the added email. + rec = do(http.MethodPost, "/api/auth/login", "", map[string]string{ + "identifier": "phoneonly@test.local", "password": "newpassword", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login with new password status = %d body = %s", rec.Code, rec.Body.String()) + } + var relogged authBody + _ = json.Unmarshal(rec.Body.Bytes(), &relogged) + + // Delete the account: it disappears immediately. + rec = do(http.MethodDelete, "/api/auth/me", relogged.Tokens.AccessToken, map[string]string{ + "password": "newpassword", + }) + if rec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d body = %s", rec.Code, rec.Body.String()) + } + rec = do(http.MethodPost, "/api/auth/login", "", map[string]string{ + "identifier": phone, "password": "newpassword", + }) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("login after delete status = %d, want 401", rec.Code) + } +} diff --git a/backend/internal/e2e/sqlite_test.go b/backend/internal/e2e/sqlite_test.go index feed2f8..4162ee2 100644 --- a/backend/internal/e2e/sqlite_test.go +++ b/backend/internal/e2e/sqlite_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "database/sql" + "errors" "image" "image/color" "image/png" @@ -80,13 +81,118 @@ func openStack(t *testing.T, path, blobRoot string) (*stack, func()) { return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc, hub: hub, tm: tm, repos: repos, storage: blob}, func() { database.Close() } } +func TestUsersSchemaSupportsPhone(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "galo.db") + st, closeDB := openStack(t, path, t.TempDir()) + defer closeDB() + + var emailNotNull int + if err := st.db.QueryRowContext(ctx, + `SELECT "notnull" FROM pragma_table_info('users') WHERE name='email'`).Scan(&emailNotNull); err != nil { + t.Fatalf("email column: %v", err) + } + if emailNotNull != 0 { + t.Fatalf("users.email is still NOT NULL") + } + + var phoneCols int + if err := st.db.QueryRowContext(ctx, + `SELECT count(*) FROM pragma_table_info('users') WHERE name='phone'`).Scan(&phoneCols); err != nil { + t.Fatalf("phone column: %v", err) + } + if phoneCols != 1 { + t.Fatalf("users.phone column missing") + } + + insert := func(id, email, phone string) error { + var e any + if email != "" { + e = email + } + var p any + if phone != "" { + p = phone + } + _, err := st.db.ExecContext(ctx, + `INSERT INTO users (id, email, phone, password_hash, name) VALUES ($1, $2, $3, 'x', 'U')`, + id, e, p) + return err + } + + // A phone-only account is allowed (email NULL). + if err := insert("u-phone-only", "", "+15550000001"); err != nil { + t.Fatalf("insert phone-only user: %v", err) + } + // A second email-less account is allowed: NULL emails do not collide. + if err := insert("u-phone-only-2", "", "+15550000002"); err != nil { + t.Fatalf("insert second phone-only user: %v", err) + } + // Phone must be unique. + if err := insert("u-dup-phone", "dup@test.local", "+15550000001"); err == nil { + t.Fatalf("duplicate phone accepted") + } + // A phone-only insert without a phone must be rejected by application + // logic; the schema itself still permits it, which is by design. + if err := insert("u-email-1", "same@test.local", ""); err != nil { + t.Fatalf("insert email user: %v", err) + } + if err := insert("u-email-dup", "same@test.local", ""); err == nil { + t.Fatalf("duplicate email accepted") + } +} + +func TestDeleteAccountRemovesSharedAlbum(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "galo.db") + st, closeDB := openStack(t, path, t.TempDir()) + defer closeDB() + + owner, _, err := st.auth.Register(ctx, "ownerdel@test.local", "", "password123", "Owner") + if err != nil { + t.Fatalf("register owner: %v", err) + } + member, _, err := st.auth.Register(ctx, "memberdel@test.local", "", "password123", "Member") + if err != nil { + t.Fatalf("register member: %v", err) + } + album, err := st.albums.Create(ctx, owner.ID, albums.CreateInput{Title: "Shared"}) + if err != nil { + t.Fatalf("create album: %v", err) + } + if _, err := st.db.ExecContext(ctx, + `INSERT INTO album_members (album_id, user_id, role) VALUES ($1, $2, 'member')`, album.ID, member.ID); err != nil { + t.Fatalf("add member: %v", err) + } + if list, err := st.repos.Albums.ListForUser(ctx, member.ID); err != nil || len(list) != 1 { + t.Fatalf("member albums before delete = %d err=%v", len(list), err) + } + + if err := st.auth.DeleteAccount(ctx, owner.ID, "password123"); err != nil { + t.Fatalf("delete account: %v", err) + } + + if list, err := st.repos.Albums.ListForUser(ctx, member.ID); err != nil || len(list) != 0 { + t.Fatalf("member albums after owner delete = %d err=%v, want 0", len(list), err) + } + if _, err := st.repos.Albums.GetByID(ctx, album.ID); !errors.Is(err, albums.ErrNotFound) { + t.Fatalf("album survived owner deletion: %v", err) + } + if role, _ := st.repos.Members.Role(ctx, album.ID, member.ID); role != "" { + t.Fatalf("membership survived: %q", role) + } + if _, err := st.repos.Auth.GetUserByID(ctx, member.ID); err != nil { + t.Fatalf("member account removed by cascade: %v", err) + } +} + func TestUploadSetsDefaultCover(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "galo.db") st, closeDB := openStack(t, path, t.TempDir()) defer closeDB() - user, _, err := st.auth.Register(ctx, "cover@test.local", "password123", "C") + user, _, err := st.auth.Register(ctx, "cover@test.local", "", "password123", "C") if err != nil { t.Fatalf("register: %v", err) } @@ -130,7 +236,7 @@ func TestCoverClearedWhenCoverPhotoLeaves(t *testing.T) { st, closeDB := openStack(t, path, t.TempDir()) defer closeDB() - user, _, err := st.auth.Register(ctx, "clear@test.local", "password123", "C") + user, _, err := st.auth.Register(ctx, "clear@test.local", "", "password123", "C") if err != nil { t.Fatalf("register: %v", err) } @@ -190,7 +296,7 @@ func TestWorkerPublishesPhotoUpdated(t *testing.T) { defer cancel() go worker.Run(wctx) - user, _, err := st.auth.Register(ctx, "evt@test.local", "password123", "E") + user, _, err := st.auth.Register(ctx, "evt@test.local", "", "password123", "E") if err != nil { t.Fatalf("register: %v", err) } @@ -228,7 +334,7 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) { wctx, cancel := context.WithCancel(ctx) go worker.Run(wctx) - user, _, err := st.auth.Register(ctx, "e2e@test.local", "password123", "E2E") + user, _, err := st.auth.Register(ctx, "e2e@test.local", "", "password123", "E2E") if err != nil { t.Fatalf("register: %v", err) } diff --git a/backend/internal/e2e/trash_test.go b/backend/internal/e2e/trash_test.go index 2c8d8bf..bb0ad4e 100644 --- a/backend/internal/e2e/trash_test.go +++ b/backend/internal/e2e/trash_test.go @@ -23,7 +23,7 @@ func TestPermanentDeleteAlbumlessPhoto(t *testing.T) { st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() - user, _, err := st.auth.Register(ctx, "albumless@test.local", "password123", "A") + user, _, err := st.auth.Register(ctx, "albumless@test.local", "", "password123", "A") if err != nil { t.Fatalf("register: %v", err) } @@ -78,11 +78,11 @@ func TestEmptyTrashSkipsForeignPhotos(t *testing.T) { st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() - owner, _, err := st.auth.Register(ctx, "owner@test.local", "password123", "O") + owner, _, err := st.auth.Register(ctx, "owner@test.local", "", "password123", "O") if err != nil { t.Fatalf("register owner: %v", err) } - member, _, err := st.auth.Register(ctx, "member@test.local", "password123", "M") + member, _, err := st.auth.Register(ctx, "member@test.local", "", "password123", "M") if err != nil { t.Fatalf("register member: %v", err) } @@ -150,7 +150,7 @@ func TestListTrashIdentitiesNotCapped(t *testing.T) { st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() - user, _, err := st.auth.Register(ctx, "many@test.local", "password123", "M") + user, _, err := st.auth.Register(ctx, "many@test.local", "", "password123", "M") if err != nil { t.Fatalf("register: %v", err) } @@ -184,7 +184,7 @@ func TestEmptyTrashReturnsZeroWhenEmpty(t *testing.T) { st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() - user, _, err := st.auth.Register(ctx, "empty@test.local", "password123", "E") + user, _, err := st.auth.Register(ctx, "empty@test.local", "", "password123", "E") if err != nil { t.Fatalf("register: %v", err) } @@ -202,7 +202,7 @@ func TestEmptyTrashHTTP(t *testing.T) { st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() - user, pair, err := st.auth.Register(ctx, "http@test.local", "password123", "H") + user, pair, err := st.auth.Register(ctx, "http@test.local", "", "password123", "H") if err != nil { t.Fatalf("register: %v", err) } diff --git a/backend/internal/e2e/video_test.go b/backend/internal/e2e/video_test.go index 54eaee0..3a001ca 100644 --- a/backend/internal/e2e/video_test.go +++ b/backend/internal/e2e/video_test.go @@ -50,7 +50,7 @@ func TestVideoUploadPreparedForPlayback(t *testing.T) { st, closeDB := openStack(t, path, t.TempDir()) defer closeDB() - user, _, err := st.auth.Register(ctx, "video@test.local", "password123", "V") + user, _, err := st.auth.Register(ctx, "video@test.local", "", "password123", "V") if err != nil { t.Fatalf("register: %v", err) } diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index f38610e..76d2352 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -5,6 +5,7 @@ import "time" type User struct { ID string `json:"id"` Email string `json:"email"` + Phone string `json:"phone,omitempty"` PasswordHash string `json:"-"` Name string `json:"name"` AvatarURL string `json:"avatar_url,omitempty"` @@ -26,6 +27,23 @@ func (u *User) Public() PublicUser { return PublicUser{ID: u.ID, Email: u.Email, Name: u.Name, AvatarURL: u.AvatarURL} } +// SelfUser is the owner-facing representation of an account. Unlike PublicUser +// it carries the phone number, which must never appear in public views. +type SelfUser struct { + ID string `json:"id"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +func (u *User) Self() SelfUser { + if u == nil { + return SelfUser{} + } + return SelfUser{ID: u.ID, Email: u.Email, Phone: u.Phone, Name: u.Name, AvatarURL: u.AvatarURL} +} + type Album struct { ID string `json:"id"` OwnerID string `json:"owner_id"` diff --git a/backend/internal/models/models_test.go b/backend/internal/models/models_test.go new file mode 100644 index 0000000..5f91f6a --- /dev/null +++ b/backend/internal/models/models_test.go @@ -0,0 +1,30 @@ +package models + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestPublicUserOmitsPhone(t *testing.T) { + u := &User{ID: "u1", Email: "user@test.local", Phone: "+15551234567", Name: "Tester"} + + pub, err := json.Marshal(u.Public()) + if err != nil { + t.Fatalf("marshal public: %v", err) + } + if strings.Contains(string(pub), "phone") || strings.Contains(string(pub), "+15551234567") { + t.Fatalf("PublicUser leaks phone: %s", pub) + } + + self, err := json.Marshal(u.Self()) + if err != nil { + t.Fatalf("marshal self: %v", err) + } + if !strings.Contains(string(self), "+15551234567") { + t.Fatalf("SelfUser missing phone: %s", self) + } + if !strings.Contains(string(self), `"phone"`) { + t.Fatalf("SelfUser phone not serialized: %s", self) + } +} diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index 9dd7df6..887416a 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -121,6 +121,46 @@ func testAuth(t *testing.T, ctx context.Context, r Repos) { if _, err := r.Auth.GetUserByEmail(ctx, "missing@test.local"); !errors.Is(err, auth.ErrNotFound) { t.Fatalf("missing email err = %v, want ErrNotFound", err) } + if _, err := r.Auth.GetUserByPhone(ctx, "+15550000000"); !errors.Is(err, auth.ErrNotFound) { + t.Fatalf("missing phone err = %v, want ErrNotFound", err) + } + + // A phone-only account stores NULL email and is retrievable by phone. + phoneUser := &models.User{ID: newID(), Phone: "+15550000001", PasswordHash: "x", Name: "Phone Only"} + if err := r.Auth.CreateUser(ctx, phoneUser); err != nil { + t.Fatalf("create phone-only user: %v", err) + } + byPhone, err := r.Auth.GetUserByPhone(ctx, phoneUser.Phone) + if err != nil || byPhone.ID != phoneUser.ID || byPhone.Email != "" { + t.Fatalf("get by phone: %v %+v", err, byPhone) + } + + // Adding the missing identifier fills only the empty column. + if err := r.Auth.AddIdentifier(ctx, phoneUser.ID, "added@test.local", ""); err != nil { + t.Fatalf("add identifier: %v", err) + } + withEmail, _ := r.Auth.GetUserByID(ctx, phoneUser.ID) + if withEmail.Email != "added@test.local" || withEmail.Phone != phoneUser.Phone { + t.Fatalf("identifier not added: %+v", withEmail) + } + + if err := r.Auth.UpdateName(ctx, u.ID, "Renamed"); err != nil { + t.Fatalf("update name: %v", err) + } + if err := r.Auth.UpdatePasswordHash(ctx, u.ID, "newhash"); err != nil { + t.Fatalf("update password: %v", err) + } + renamed, _ := r.Auth.GetUserByID(ctx, u.ID) + if renamed.Name != "Renamed" || renamed.PasswordHash != "newhash" { + t.Fatalf("user updates not applied: %+v", renamed) + } + + if err := r.Auth.DeleteUser(ctx, phoneUser.ID); err != nil { + t.Fatalf("delete user: %v", err) + } + if _, err := r.Auth.GetUserByID(ctx, phoneUser.ID); !errors.Is(err, auth.ErrNotFound) { + t.Fatalf("deleted user still present") + } if err := r.Auth.UpdatePreferences(ctx, u.ID, []byte(`{"theme":"dark"}`)); err != nil { t.Fatalf("update preferences: %v", err) diff --git a/backend/migrations/000013_user_phone.down.sql b/backend/migrations/000013_user_phone.down.sql new file mode 100644 index 0000000..c139db0 --- /dev/null +++ b/backend/migrations/000013_user_phone.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_phone_key; +ALTER TABLE users DROP COLUMN IF EXISTS phone; +ALTER TABLE users ALTER COLUMN email SET NOT NULL; diff --git a/backend/migrations/000013_user_phone.up.sql b/backend/migrations/000013_user_phone.up.sql new file mode 100644 index 0000000..2011351 --- /dev/null +++ b/backend/migrations/000013_user_phone.up.sql @@ -0,0 +1,6 @@ +-- A second login identifier: phone (E.164), nullable and unique. Email becomes +-- optional so phone-only accounts are possible; the "at least one identifier" +-- rule is enforced in the application layer. +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text; +ALTER TABLE users ADD CONSTRAINT users_phone_key UNIQUE (phone); diff --git a/backend/migrations/sqlite/000005_user_phone.down.sql b/backend/migrations/sqlite/000005_user_phone.down.sql new file mode 100644 index 0000000..5dfd968 --- /dev/null +++ b/backend/migrations/sqlite/000005_user_phone.down.sql @@ -0,0 +1,21 @@ +-- Email is restored to NOT NULL, so phone-only accounts (email IS NULL) cannot +-- be represented and are dropped. Down is only safe when no such accounts exist. +DELETE FROM users WHERE email IS NULL; + +CREATE TABLE users_old ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + avatar_url TEXT, + preferences TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +INSERT INTO users_old (id, email, password_hash, name, avatar_url, preferences, created_at) +SELECT id, email, password_hash, name, avatar_url, preferences, created_at +FROM users WHERE email IS NOT NULL; + +DROP TABLE users; + +ALTER TABLE users_old RENAME TO users; diff --git a/backend/migrations/sqlite/000005_user_phone.up.sql b/backend/migrations/sqlite/000005_user_phone.up.sql new file mode 100644 index 0000000..0a6ac77 --- /dev/null +++ b/backend/migrations/sqlite/000005_user_phone.up.sql @@ -0,0 +1,20 @@ +-- SQLite cannot drop a NOT NULL constraint via ALTER, so rebuild the users +-- table: email becomes nullable, phone is added as a unique second identifier, +-- and existing rows are carried across (phone stays NULL). +CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE, + phone TEXT UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + avatar_url TEXT, + preferences TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +INSERT INTO users_new (id, email, phone, password_hash, name, avatar_url, preferences, created_at) +SELECT id, email, NULL, password_hash, name, avatar_url, preferences, created_at FROM users; + +DROP TABLE users; + +ALTER TABLE users_new RENAME TO users; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6c98cf4..0d23e97 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -86,19 +86,27 @@ export async function api(path: string, opts: RequestOptions = {}): } export const authApi = { - register: (email: string, password: string, name: string) => + register: (email: string, phone: string, password: string, name: string) => api<{ user: User; tokens: Tokens }>('/auth/register', { method: 'POST', - body: { email, password, name } + body: { email, phone, password, name } }), - login: (email: string, password: string) => + login: (identifier: string, password: string) => api<{ user: User; tokens: Tokens }>('/auth/login', { method: 'POST', - body: { email, password } + body: { identifier, password } }), logout: (refresh_token: string) => api('/auth/logout', { method: 'POST', body: { refresh_token } }), me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'), + updateProfile: (name: string) => + api<{ user: User }>('/auth/me', { method: 'PATCH', body: { name } }), + changePassword: (current_password: string, new_password: string) => + api('/auth/me/password', { method: 'POST', body: { current_password, new_password } }), + addIdentifier: (email: string, phone: string) => + api<{ user: User }>('/auth/me/identifiers', { method: 'POST', body: { email, phone } }), + deleteAccount: (password: string) => + api('/auth/me', { method: 'DELETE', body: { password } }), savePreferences: (preferences: Preferences) => api<{ preferences: Preferences }>('/auth/me/preferences', { method: 'PATCH', diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index c960c15..40b14c8 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -133,7 +133,14 @@ {/if} {#if auth.isAuthenticated} - + + + + + + +{#if !user?.email || !user?.phone} +
+

Добавить идентификатор

+ {#if !user?.email} + + {/if} + {#if !user?.phone} + + {/if} +

Заполните одно поле — заданный идентификатор изменить нельзя.

+ +
+{:else} +
+

Добавить идентификатор

+

Оба идентификатора заданы.

+
+{/if} + +
+

Смена пароля

+ + +

После смены пароля все сессии будут завершены.

+ +
+ +
+

Удаление аккаунта

+

+ Аккаунт и всё его содержимое удаляются немедленно и необратимо. +

+ + +
diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte index 01611ce..6e9e770 100644 --- a/frontend/src/routes/(public)/login/+page.svelte +++ b/frontend/src/routes/(public)/login/+page.svelte @@ -4,7 +4,9 @@ import { auth } from '$lib/stores/auth.svelte'; let mode = $state<'login' | 'register'>('login'); + let identifier = $state(''); let email = $state(''); + let phone = $state(''); let password = $state(''); let name = $state(''); let error = $state(''); @@ -13,12 +15,16 @@ async function submit(ev: Event) { ev.preventDefault(); error = ''; + if (mode === 'register' && !email.trim() && !phone.trim()) { + error = 'Укажите email или телефон'; + return; + } busy = true; try { const data = mode === 'login' - ? await authApi.login(email, password) - : await authApi.register(email, password, name); + ? await authApi.login(identifier, password) + : await authApi.register(email, phone, password, name); auth.setTokens(data.tokens); auth.setUser(data.user); goto('/'); @@ -43,16 +49,34 @@ class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2" /> + + +

Нужен хотя бы один из email или телефона.

+ {:else} + {/if} -