feat(auth): add user profile, phone login, and account deletion

This commit is contained in:
iu
2026-10-12 14:26:32 +03:00
parent 1cb9aaf198
commit 86aff695df
46 changed files with 2893 additions and 134 deletions
+9 -2
View File
@@ -196,9 +196,13 @@ GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
| Переменная | По умолчанию | Описание | | Переменная | По умолчанию | Описание |
| --- | --- | --- | | --- | --- | --- |
| `GALO_BASE_URL` | `http://localhost:8080` | Базовый URL инстанса | | `GALO_BASE_URL` | `http://localhost:8080` | Базовый URL инстанса |
| `GALO_EMAIL` | — | Email владельца (обязательно) | | `GALO_EMAIL` | — | Email владельца (нужен один из email/телефона) |
| `GALO_PHONE` | — | Телефон владельца в E.164 (нужен один из email/телефона) |
| `GALO_PASSWORD` | — | Пароль владельца (обязательно) | | `GALO_PASSWORD` | — | Пароль владельца (обязательно) |
Вход выполняется по единому идентификатору: при заданном `GALO_EMAIL` используется он,
иначе — `GALO_PHONE`.
Параметры: Параметры:
| Флаг | По умолчанию | Описание | | Флаг | По умолчанию | Описание |
@@ -300,7 +304,10 @@ deploy/ # systemd unit, пример nginx
Полный список эндпоинтов: `project.md`, раздел 11. Кратко: Полный список эндпоинтов: `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|POST /api/albums`, `GET|PATCH|DELETE /api/albums/:id`
- `GET /api/albums/:id/members`, `POST /api/albums/:id/invites`, - `GET /api/albums/:id/members`, `POST /api/albums/:id/invites`,
`POST /api/invites/:token/accept`, `PATCH|DELETE /api/albums/:id/members/:user_id` `POST /api/invites/:token/accept`, `PATCH|DELETE /api/albums/:id/members/:user_id`
@@ -18,6 +18,7 @@ import com.galo.android.data.prefs.SettingsState
import com.galo.android.ui.album.AlbumScreen import com.galo.android.ui.album.AlbumScreen
import com.galo.android.ui.albums.AlbumsScreen import com.galo.android.ui.albums.AlbumsScreen
import com.galo.android.ui.auth.AuthScreen 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.setup.SetupScreen
import com.galo.android.ui.upload.UploadScreen import com.galo.android.ui.upload.UploadScreen
import com.galo.android.ui.viewer.ViewerScreen import com.galo.android.ui.viewer.ViewerScreen
@@ -27,6 +28,7 @@ object Routes {
const val AUTH = "auth" const val AUTH = "auth"
const val ALBUMS = "albums" const val ALBUMS = "albums"
const val UPLOADS = "uploads" const val UPLOADS = "uploads"
const val PROFILE = "profile"
const val ALBUM = "album/{albumId}" const val ALBUM = "album/{albumId}"
const val VIEWER = "album/{albumId}/viewer?index={index}" const val VIEWER = "album/{albumId}/viewer?index={index}"
@@ -82,6 +84,7 @@ fun GaloNavHost(app: GaloApplication) {
factory = factory, factory = factory,
onOpenAlbum = { id -> navController.navigate(Routes.album(id)) }, onOpenAlbum = { id -> navController.navigate(Routes.album(id)) },
onOpenUploads = { navController.navigate(Routes.UPLOADS) }, onOpenUploads = { navController.navigate(Routes.UPLOADS) },
onOpenProfile = { navController.navigate(Routes.PROFILE) },
onLoggedOut = { onLoggedOut = {
navController.navigate(Routes.AUTH) { navController.navigate(Routes.AUTH) {
popUpTo(Routes.ALBUMS) { inclusive = true } 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) { composable(Routes.UPLOADS) {
UploadScreen( UploadScreen(
factory = factory, factory = factory,
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
import com.galo.android.GaloApplication import com.galo.android.GaloApplication
import com.galo.android.ui.albums.AlbumsViewModel import com.galo.android.ui.albums.AlbumsViewModel
import com.galo.android.ui.auth.AuthViewModel 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.setup.SetupViewModel
import com.galo.android.ui.upload.UploadViewModel 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(AuthViewModel::class.java) -> AuthViewModel(container)
modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container) modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container)
modelClass.isAssignableFrom(UploadViewModel::class.java) -> UploadViewModel(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}") else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
} }
return viewModel as T return viewModel as T
@@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -50,6 +51,7 @@ fun AlbumsScreen(
factory: GaloViewModelFactory, factory: GaloViewModelFactory,
onOpenAlbum: (String) -> Unit, onOpenAlbum: (String) -> Unit,
onOpenUploads: () -> Unit, onOpenUploads: () -> Unit,
onOpenProfile: () -> Unit,
onLoggedOut: () -> Unit, onLoggedOut: () -> Unit,
) { ) {
val viewModel: AlbumsViewModel = viewModel(factory = factory) val viewModel: AlbumsViewModel = viewModel(factory = factory)
@@ -58,6 +60,7 @@ fun AlbumsScreen(
state = state, state = state,
onOpenAlbum = onOpenAlbum, onOpenAlbum = onOpenAlbum,
onOpenUploads = onOpenUploads, onOpenUploads = onOpenUploads,
onOpenProfile = onOpenProfile,
onRefresh = viewModel::refresh, onRefresh = viewModel::refresh,
onLogout = { viewModel.logout(onLoggedOut) }, onLogout = { viewModel.logout(onLoggedOut) },
) )
@@ -69,6 +72,7 @@ fun AlbumsContent(
state: AlbumsViewModel.UiState, state: AlbumsViewModel.UiState,
onOpenAlbum: (String) -> Unit, onOpenAlbum: (String) -> Unit,
onOpenUploads: () -> Unit, onOpenUploads: () -> Unit,
onOpenProfile: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
) { ) {
@@ -77,6 +81,9 @@ fun AlbumsContent(
TopAppBar( TopAppBar(
title = { Text("Альбомы") }, title = { Text("Альбомы") },
actions = { actions = {
IconButton(onClick = onOpenProfile) {
Icon(Icons.Filled.Person, contentDescription = "Профиль")
}
IconButton(onClick = onOpenUploads) { IconButton(onClick = onOpenUploads) {
Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки") Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки")
} }
@@ -22,10 +22,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.galo.android.ui.GaloViewModelFactory import com.galo.android.ui.GaloViewModelFactory
const val TAG_AUTH_IDENTIFIER = "auth_identifier"
const val TAG_AUTH_EMAIL = "auth_email" 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_PASSWORD = "auth_password"
const val TAG_AUTH_NAME = "auth_name" const val TAG_AUTH_NAME = "auth_name"
const val TAG_AUTH_SUBMIT = "auth_submit" const val TAG_AUTH_SUBMIT = "auth_submit"
const val TAG_AUTH_TOGGLE = "auth_toggle"
@Composable @Composable
fun AuthScreen( fun AuthScreen(
@@ -39,6 +42,37 @@ fun AuthScreen(
viewModel.authenticated.collect { onAuthenticated() } 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -52,23 +86,44 @@ fun AuthScreen(
if (state.mode == AuthViewModel.Mode.REGISTER) { if (state.mode == AuthViewModel.Mode.REGISTER) {
OutlinedTextField( OutlinedTextField(
value = state.name, value = state.name,
onValueChange = viewModel::onNameChange, onValueChange = onNameChange,
label = { Text("Имя") }, label = { Text("Имя") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_NAME), 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( OutlinedTextField(
value = state.password, value = state.password,
onValueChange = viewModel::onPasswordChange, onValueChange = onPasswordChange,
label = { Text("Пароль") }, label = { Text("Пароль") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
@@ -79,7 +134,7 @@ fun AuthScreen(
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
} }
Button( Button(
onClick = viewModel::submit, onClick = onSubmit,
enabled = !state.loading, enabled = !state.loading,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_SUBMIT), modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_SUBMIT),
) { ) {
@@ -93,15 +148,8 @@ fun AuthScreen(
} }
Row { Row {
TextButton( TextButton(
onClick = { onClick = onToggleMode,
viewModel.setMode( modifier = Modifier.testTag(TAG_AUTH_TOGGLE),
if (state.mode == AuthViewModel.Mode.LOGIN) {
AuthViewModel.Mode.REGISTER
} else {
AuthViewModel.Mode.LOGIN
},
)
},
) { ) {
Text( Text(
if (state.mode == AuthViewModel.Mode.LOGIN) { if (state.mode == AuthViewModel.Mode.LOGIN) {
@@ -18,7 +18,9 @@ class AuthViewModel(private val container: AppContainer) : ViewModel() {
data class UiState( data class UiState(
val mode: Mode = Mode.LOGIN, val mode: Mode = Mode.LOGIN,
val identifier: String = "",
val email: String = "", val email: String = "",
val phone: String = "",
val password: String = "", val password: String = "",
val name: String = "", val name: String = "",
val loading: Boolean = false, val loading: Boolean = false,
@@ -31,8 +33,13 @@ class AuthViewModel(private val container: AppContainer) : ViewModel() {
private val _authenticated = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _authenticated = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val authenticated = _authenticated.asSharedFlow() 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 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 onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun onNameChange(value: String) = _state.update { it.copy(name = 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() { fun submit() {
val current = _state.value val current = _state.value
val identifier = current.identifier.trim()
val email = current.email.trim() val email = current.email.trim()
when { val phone = current.phone.trim()
email.isEmpty() || current.password.isEmpty() -> {
_state.update { it.copy(error = "Заполните email и пароль") }
return
}
current.mode == Mode.REGISTER && current.name.trim().isEmpty() -> { val validationError = when {
_state.update { it.copy(error = "Укажите имя") } current.password.isEmpty() -> "Введите пароль"
return 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) } _state.update { it.copy(loading = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
try { try {
val tokens = when (current.mode) { val tokens = when (current.mode) {
Mode.LOGIN -> container.api.login(email, current.password) Mode.LOGIN -> container.api.login(identifier, current.password)
Mode.REGISTER -> container.api.register(email, current.password, current.name.trim()) Mode.REGISTER ->
container.api.register(email, phone, current.password, current.name.trim())
} }
container.tokenStore.save(tokens.accessToken, tokens.refreshToken) container.tokenStore.save(tokens.accessToken, tokens.refreshToken)
_state.update { it.copy(loading = false) } _state.update { it.copy(loading = false) }
@@ -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)
}
}
}
}
@@ -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<Unit>(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 ?: "Ошибка сохранения") }
}
}
}
}
@@ -40,6 +40,7 @@ class AlbumsContentTest {
state = AlbumsViewModel.UiState(albums = listOf(album("a1", "Summer"))), state = AlbumsViewModel.UiState(albums = listOf(album("a1", "Summer"))),
onOpenAlbum = {}, onOpenAlbum = {},
onOpenUploads = {}, onOpenUploads = {},
onOpenProfile = {},
onRefresh = {}, onRefresh = {},
onLogout = {}, onLogout = {},
) )
@@ -63,6 +64,7 @@ class AlbumsContentTest {
), ),
onOpenAlbum = {}, onOpenAlbum = {},
onOpenUploads = {}, onOpenUploads = {},
onOpenProfile = {},
onRefresh = {}, onRefresh = {},
onLogout = {}, onLogout = {},
) )
@@ -79,6 +81,7 @@ class AlbumsContentTest {
state = AlbumsViewModel.UiState(), state = AlbumsViewModel.UiState(),
onOpenAlbum = {}, onOpenAlbum = {},
onOpenUploads = {}, onOpenUploads = {},
onOpenProfile = {},
onRefresh = {}, onRefresh = {},
onLogout = {}, onLogout = {},
) )
@@ -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()
}
}
@@ -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)
}
}
@@ -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<String, String>? = 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)
}
}
+4
View File
@@ -133,6 +133,10 @@ func (s *Server) Router() *gin.Engine {
authed.Use(s.auth.Middleware()) authed.Use(s.auth.Middleware())
authed.GET("/auth/me", authHandler.Me) 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.PATCH("/auth/me/preferences", authHandler.UpdatePreferences)
authed.GET("/sse/albums/:id", s.albumSSE) authed.GET("/sse/albums/:id", s.albumSSE)
authed.GET("/sse/me", s.meSSE) authed.GET("/sse/me", s.meSSE)
+87 -10
View File
@@ -18,7 +18,8 @@ type Handler struct {
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
type registerRequest struct { type registerRequest struct {
Email string `json:"email" binding:"required"` Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
} }
@@ -26,34 +27,34 @@ type registerRequest struct {
func (h *Handler) Register(c *gin.Context) { func (h *Handler) Register(c *gin.Context) {
var req registerRequest var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil { 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 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 { if err != nil {
httpx.RespondError(c, err) httpx.RespondError(c, err)
return 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 { type loginRequest struct {
Email string `json:"email" binding:"required"` Identifier string `json:"identifier" binding:"required"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
} }
func (h *Handler) Login(c *gin.Context) { func (h *Handler) Login(c *gin.Context) {
var req loginRequest var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil { 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 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 { if err != nil {
httpx.RespondError(c, err) httpx.RespondError(c, err)
return 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 { type refreshRequest struct {
@@ -96,7 +97,83 @@ func (h *Handler) Me(c *gin.Context) {
if prefs == nil { if prefs == nil {
prefs = map[string]any{} 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 ( const (
+215
View File
@@ -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)
}
}
+63
View File
@@ -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
}
+63
View File
@@ -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")
}
}
+7
View File
@@ -10,7 +10,14 @@ import (
type Repository interface { type Repository interface {
CreateUser(ctx context.Context, u *models.User) error CreateUser(ctx context.Context, u *models.User) error
GetUserByEmail(ctx context.Context, email string) (*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) 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 UpdatePreferences(ctx context.Context, userID string, raw []byte) error
CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error
GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error)
+185 -17
View File
@@ -26,23 +26,38 @@ func NewService(store Repository, tm *TokenManager) *Service {
return &Service{store: store, tm: tm} return &Service{store: store, tm: tm}
} }
func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, *TokenPair, error) { // Register creates an account with at least one of email/phone. A missing
email = strings.TrimSpace(strings.ToLower(email)) // 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) name = strings.TrimSpace(name)
if email == "" || !strings.Contains(email, "@") { if name == "" {
return nil, nil, httpx.BadRequest("invalid_email", "valid email is required") return nil, nil, httpx.BadRequest("invalid_name", "name is required")
} }
if len(password) < 8 { if len(password) < 8 {
return nil, nil, httpx.BadRequest("weak_password", "password must be at least 8 characters") 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 { if normEmail != "" {
return nil, nil, httpx.Conflict("email_taken", "email is already registered") if _, err := s.store.GetUserByEmail(ctx, normEmail); err == nil {
} else if !errors.Is(err, ErrNotFound) { return nil, nil, httpx.Conflict("email_taken", "email is already registered")
return nil, nil, err } 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) hash, err := HashPassword(password)
@@ -51,13 +66,14 @@ func (s *Service) Register(ctx context.Context, email, password, name string) (*
} }
u := &models.User{ u := &models.User{
ID: NewID(), ID: NewID(),
Email: email, Email: normEmail,
Phone: normPhone,
PasswordHash: hash, PasswordHash: hash,
Name: name, Name: name,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := s.store.CreateUser(ctx, u); err != nil { if err := s.store.CreateUser(ctx, u); err != nil {
return nil, nil, err return nil, nil, mapIdentifierConflict(err)
} }
pair, err := s.issuePair(ctx, u) 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 return u, pair, nil
} }
func (s *Service) Login(ctx context.Context, email, password string) (*models.User, *TokenPair, error) { // Login authenticates by a single identifier — email or E.164 phone — and never
email = strings.TrimSpace(strings.ToLower(email)) // reveals which part of the credentials was wrong.
u, err := s.store.GetUserByEmail(ctx, email) 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 err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password") return nil, nil, invalidCredentials()
} }
return nil, nil, err return nil, nil, err
} }
if !CheckPassword(u.PasswordHash, password) { 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) pair, err := s.issuePair(ctx, u)
if err != nil { if err != nil {
@@ -120,6 +146,148 @@ func (s *Service) Logout(ctx context.Context, refreshToken string) error {
return s.store.RevokeRefreshToken(ctx, t.ID) 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) { func (s *Service) issuePair(ctx context.Context, u *models.User) (*TokenPair, error) {
access, err := s.tm.GenerateAccess(u.ID, u.Email) access, err := s.tm.GenerateAccess(u.ID, u.Email)
if err != nil { if err != nil {
+239
View File
@@ -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)
}
}
+61 -13
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"strings"
"time" "time"
"galo/internal/models" "galo/internal/models"
@@ -18,32 +19,36 @@ type SQLiteStore struct {
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} } 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 { func (s *SQLiteStore) CreateUser(ctx context.Context, u *models.User) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, email, password_hash, name, avatar_url) `INSERT INTO users (id, email, phone, password_hash, name, avatar_url)
VALUES ($1, $2, $3, $4, $5)`, VALUES ($1, $2, $3, $4, $5, $6)`,
u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL)) u.ID, nullIfEmpty(u.Email), nullIfEmpty(u.Phone), u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL))
return err return mapUniqueViolationSQLite(err)
} }
func (s *SQLiteStore) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { func (s *SQLiteStore) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.db.QueryRowContext(ctx, return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), `SELECT `+sqliteUserColumns+` FROM users WHERE lower(email) = lower($1)`, email))
COALESCE(preferences, '{}'), created_at }
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) { func (s *SQLiteStore) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.db.QueryRowContext(ctx, return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), `SELECT `+sqliteUserColumns+` FROM users WHERE id = $1`, id))
COALESCE(preferences, '{}'), created_at
FROM users WHERE id = $1`, id))
} }
func (s *SQLiteStore) scanUser(row *sql.Row) (*models.User, error) { func (s *SQLiteStore) scanUser(row *sql.Row) (*models.User, error) {
var u models.User var u models.User
var prefs, createdAt string 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -56,9 +61,34 @@ func (s *SQLiteStore) scanUser(row *sql.Row) (*models.User, error) {
return &u, nil 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 { func (s *SQLiteStore) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
res, err := s.db.ExecContext(ctx, return s.requireRow(s.db.ExecContext(ctx,
`UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw)) `UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw)))
}
func (s *SQLiteStore) requireRow(res sql.Result, err error) error {
if err != nil { if err != nil {
return err return err
} }
@@ -109,3 +139,21 @@ func (s *SQLiteStore) RevokeAllUserTokens(ctx context.Context, userID string) er
userID, sqliteutil.FormatTime(time.Now())) userID, sqliteutil.FormatTime(time.Now()))
return err 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
}
+84 -18
View File
@@ -4,8 +4,10 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"galo/internal/models" "galo/internal/models"
@@ -13,39 +15,51 @@ import (
var ErrNotFound = errors.New("not found") 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 { type Store struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: 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 { func (s *Store) CreateUser(ctx context.Context, u *models.User) error {
_, err := s.pool.Exec(ctx, _, err := s.pool.Exec(ctx,
`INSERT INTO users (id, email, password_hash, name, avatar_url) `INSERT INTO users (id, email, phone, password_hash, name, avatar_url)
VALUES ($1, $2, $3, $4, $5)`, VALUES ($1, $2, $3, $4, $5, $6)`,
u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL), 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) { func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx, return s.scanUser(s.pool.QueryRow(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), `SELECT `+userColumns+` FROM users WHERE lower(email) = lower($1)`, email))
COALESCE(preferences, '{}'::jsonb)::text, created_at }
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) { func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx, return s.scanUser(s.pool.QueryRow(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), `SELECT `+userColumns+` FROM users WHERE id = $1`, id))
COALESCE(preferences, '{}'::jsonb)::text, created_at
FROM users WHERE id = $1`, id))
} }
func (s *Store) scanUser(row pgx.Row) (*models.User, error) { func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
var u models.User var u models.User
var prefs string 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -57,16 +71,38 @@ func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
return &u, nil 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 { func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
tag, err := s.pool.Exec(ctx, tag, err := s.pool.Exec(ctx,
`UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw)) `UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw))
if err != nil { return requireRow(tag, err)
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
} }
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error { func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
@@ -111,3 +147,33 @@ func nullIfEmpty(s string) *string {
} }
return &s 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
}
+132
View File
@@ -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(), &registered); 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)
}
}
+110 -4
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"database/sql" "database/sql"
"errors"
"image" "image"
"image/color" "image/color"
"image/png" "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() } 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) { func TestUploadSetsDefaultCover(t *testing.T) {
ctx := context.Background() ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db") path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir()) st, closeDB := openStack(t, path, t.TempDir())
defer closeDB() 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 { if err != nil {
t.Fatalf("register: %v", err) t.Fatalf("register: %v", err)
} }
@@ -130,7 +236,7 @@ func TestCoverClearedWhenCoverPhotoLeaves(t *testing.T) {
st, closeDB := openStack(t, path, t.TempDir()) st, closeDB := openStack(t, path, t.TempDir())
defer closeDB() 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 { if err != nil {
t.Fatalf("register: %v", err) t.Fatalf("register: %v", err)
} }
@@ -190,7 +296,7 @@ func TestWorkerPublishesPhotoUpdated(t *testing.T) {
defer cancel() defer cancel()
go worker.Run(wctx) 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 { if err != nil {
t.Fatalf("register: %v", err) t.Fatalf("register: %v", err)
} }
@@ -228,7 +334,7 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) {
wctx, cancel := context.WithCancel(ctx) wctx, cancel := context.WithCancel(ctx)
go worker.Run(wctx) 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 { if err != nil {
t.Fatalf("register: %v", err) t.Fatalf("register: %v", err)
} }
+6 -6
View File
@@ -23,7 +23,7 @@ func TestPermanentDeleteAlbumlessPhoto(t *testing.T) {
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB() 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 { if err != nil {
t.Fatalf("register: %v", err) 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()) st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB() 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 { if err != nil {
t.Fatalf("register owner: %v", err) 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 { if err != nil {
t.Fatalf("register member: %v", err) 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()) st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB() 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 { if err != nil {
t.Fatalf("register: %v", err) 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()) st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB() 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 { if err != nil {
t.Fatalf("register: %v", err) 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()) st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB() 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 { if err != nil {
t.Fatalf("register: %v", err) t.Fatalf("register: %v", err)
} }
+1 -1
View File
@@ -50,7 +50,7 @@ func TestVideoUploadPreparedForPlayback(t *testing.T) {
st, closeDB := openStack(t, path, t.TempDir()) st, closeDB := openStack(t, path, t.TempDir())
defer closeDB() 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 { if err != nil {
t.Fatalf("register: %v", err) t.Fatalf("register: %v", err)
} }
+18
View File
@@ -5,6 +5,7 @@ import "time"
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone,omitempty"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"` 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} 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 { type Album struct {
ID string `json:"id"` ID string `json:"id"`
OwnerID string `json:"owner_id"` OwnerID string `json:"owner_id"`
+30
View File
@@ -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)
}
}
@@ -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) { if _, err := r.Auth.GetUserByEmail(ctx, "missing@test.local"); !errors.Is(err, auth.ErrNotFound) {
t.Fatalf("missing email err = %v, want ErrNotFound", err) 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 { if err := r.Auth.UpdatePreferences(ctx, u.ID, []byte(`{"theme":"dark"}`)); err != nil {
t.Fatalf("update preferences: %v", err) t.Fatalf("update preferences: %v", err)
@@ -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;
@@ -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);
@@ -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;
@@ -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;
+12 -4
View File
@@ -86,19 +86,27 @@ export async function api<T = unknown>(path: string, opts: RequestOptions = {}):
} }
export const authApi = { 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', { api<{ user: User; tokens: Tokens }>('/auth/register', {
method: 'POST', 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', { api<{ user: User; tokens: Tokens }>('/auth/login', {
method: 'POST', method: 'POST',
body: { email, password } body: { identifier, password }
}), }),
logout: (refresh_token: string) => logout: (refresh_token: string) =>
api('/auth/logout', { method: 'POST', body: { refresh_token } }), api('/auth/logout', { method: 'POST', body: { refresh_token } }),
me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'), 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) => savePreferences: (preferences: Preferences) =>
api<{ preferences: Preferences }>('/auth/me/preferences', { api<{ preferences: Preferences }>('/auth/me/preferences', {
method: 'PATCH', method: 'PATCH',
+8 -1
View File
@@ -133,7 +133,14 @@
</button> </button>
{/if} {/if}
{#if auth.isAuthenticated} {#if auth.isAuthenticated}
<span class="text-text-muted hidden text-xs sm:inline">{auth.user?.name ?? ''}</span> <a
href="/profile"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
title="Профиль"
>
<Icon name="settings" class="size-4" />
<span class="hidden sm:inline">{auth.user?.name ?? 'Профиль'}</span>
</a>
<button <button
type="button" type="button"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs" class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
+2 -1
View File
@@ -2,7 +2,8 @@ import type { Preferences } from '$lib/types';
export interface User { export interface User {
id: string; id: string;
email: string; email?: string;
phone?: string;
name: string; name: string;
avatar_url?: string; avatar_url?: string;
preferences?: Preferences; preferences?: Preferences;
+1
View File
@@ -10,6 +10,7 @@ export interface Preferences {
export interface User { export interface User {
id: string; id: string;
email?: string; email?: string;
phone?: string;
name: string; name: string;
avatar_url?: string; avatar_url?: string;
preferences?: Preferences; preferences?: Preferences;
@@ -0,0 +1,226 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authApi } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
let user = $state(auth.user);
let name = $state(auth.user?.name ?? '');
let newEmail = $state('');
let newPhone = $state('');
let currentPassword = $state('');
let newPassword = $state('');
let deletePassword = $state('');
let error = $state('');
let notice = $state('');
$effect(() => {
load();
});
async function load() {
try {
const data = await authApi.me();
user = data.user;
auth.setUser(data.user);
name = data.user.name;
} catch (e) {
error = (e as Error).message;
}
}
function applyUser(updated: typeof user) {
user = updated;
if (updated) auth.setUser(updated);
}
async function saveName(ev: Event) {
ev.preventDefault();
error = '';
notice = '';
try {
const data = await authApi.updateProfile(name);
applyUser(data.user);
notice = 'Имя сохранено';
} catch (e) {
error = (e as Error).message;
}
}
async function addIdentifier(ev: Event) {
ev.preventDefault();
error = '';
notice = '';
try {
const data = await authApi.addIdentifier(newEmail, newPhone);
applyUser(data.user);
newEmail = '';
newPhone = '';
notice = 'Идентификатор добавлен';
} catch (e) {
error = (e as Error).message;
}
}
async function changePassword(ev: Event) {
ev.preventDefault();
error = '';
notice = '';
try {
await authApi.changePassword(currentPassword, newPassword);
auth.clear();
goto('/login');
} catch (e) {
error = (e as Error).message;
}
}
async function removeAccount() {
error = '';
if (!confirm('Удалить аккаунт со всем содержимым? Действие необратимо.')) return;
try {
await authApi.deleteAccount(deletePassword);
auth.clear();
goto('/login');
} catch (e) {
error = (e as Error).message;
}
}
</script>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Профиль</h1>
<a href="/" class="text-primary inline-flex items-center gap-1 text-sm">
<Icon name="arrow_back" class="size-4" />
К альбомам
</a>
</div>
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
{#if notice}<p class="text-success mb-4 text-sm">{notice}</p>{/if}
<div class="border-border bg-bg-elevated mb-6 rounded-xl border p-4">
<h2 class="mb-2 font-semibold">Идентификаторы</h2>
<ul class="text-text-muted space-y-1 text-sm">
<li>Email: {user?.email || '—'}</li>
<li>Телефон: {user?.phone || '—'}</li>
</ul>
</div>
<form class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4" onsubmit={saveName}>
<h2 class="font-semibold">Имя</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Отображаемое имя</span>
<input
bind:value={name}
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="save" class="size-4" />
Сохранить
</button>
</form>
{#if !user?.email || !user?.phone}
<form
class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4"
onsubmit={addIdentifier}
>
<h2 class="font-semibold">Добавить идентификатор</h2>
{#if !user?.email}
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Email</span>
<input
bind:value={newEmail}
type="email"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{/if}
{#if !user?.phone}
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Телефон (E.164)</span>
<input
bind:value={newPhone}
type="tel"
placeholder="+15551234567"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{/if}
<p class="text-text-muted text-xs">Заполните одно поле — заданный идентификатор изменить нельзя.</p>
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="add" class="size-4" />
Добавить
</button>
</form>
{:else}
<div class="border-border bg-bg-elevated mb-6 rounded-xl border p-4">
<h2 class="mb-2 font-semibold">Добавить идентификатор</h2>
<p class="text-text-muted text-sm">Оба идентификатора заданы.</p>
</div>
{/if}
<form
class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4"
onsubmit={changePassword}
>
<h2 class="font-semibold">Смена пароля</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Текущий пароль</span>
<input
bind:value={currentPassword}
type="password"
required
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Новый пароль</span>
<input
bind:value={newPassword}
type="password"
required
minlength="8"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<p class="text-text-muted text-xs">После смены пароля все сессии будут завершены.</p>
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="save" class="size-4" />
Сменить пароль
</button>
</form>
<div class="border-danger/50 bg-bg-elevated space-y-3 rounded-xl border p-4">
<h2 class="text-danger font-semibold">Удаление аккаунта</h2>
<p class="text-text-muted text-sm">
Аккаунт и всё его содержимое удаляются немедленно и необратимо.
</p>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Подтвердите пароль</span>
<input
bind:value={deletePassword}
type="password"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<button
type="button"
class="border-danger text-danger inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={removeAccount}
>
<Icon name="delete_forever" class="size-4" />
Удалить аккаунт
</button>
</div>
+35 -11
View File
@@ -4,7 +4,9 @@
import { auth } from '$lib/stores/auth.svelte'; import { auth } from '$lib/stores/auth.svelte';
let mode = $state<'login' | 'register'>('login'); let mode = $state<'login' | 'register'>('login');
let identifier = $state('');
let email = $state(''); let email = $state('');
let phone = $state('');
let password = $state(''); let password = $state('');
let name = $state(''); let name = $state('');
let error = $state(''); let error = $state('');
@@ -13,12 +15,16 @@
async function submit(ev: Event) { async function submit(ev: Event) {
ev.preventDefault(); ev.preventDefault();
error = ''; error = '';
if (mode === 'register' && !email.trim() && !phone.trim()) {
error = 'Укажите email или телефон';
return;
}
busy = true; busy = true;
try { try {
const data = const data =
mode === 'login' mode === 'login'
? await authApi.login(email, password) ? await authApi.login(identifier, password)
: await authApi.register(email, password, name); : await authApi.register(email, phone, password, name);
auth.setTokens(data.tokens); auth.setTokens(data.tokens);
auth.setUser(data.user); auth.setUser(data.user);
goto('/'); goto('/');
@@ -43,16 +49,34 @@
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2" class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/> />
</label> </label>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Email (необязательно)</span>
<input
bind:value={email}
type="email"
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Телефон (необязательно)</span>
<input
bind:value={phone}
type="tel"
placeholder="+15551234567"
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<p class="text-text-muted text-xs">Нужен хотя бы один из email или телефона.</p>
{:else}
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Email или телефон</span>
<input
bind:value={identifier}
required
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{/if} {/if}
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Email</span>
<input
bind:value={email}
type="email"
required
class="border-border bg-bg-elevated text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<label class="block"> <label class="block">
<span class="text-text-muted mb-1 block text-sm">Пароль</span> <span class="text-text-muted mb-1 block text-sm">Пароль</span>
<input <input
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12
+116
View File
@@ -0,0 +1,116 @@
# Design
## Context
См. `proposal.md` — Why. Существенное для реализации:
- `users.email` сейчас `NOT NULL UNIQUE` в обеих схемах; уникальность — `users_email_key`.
- Два хранилища с общим интерфейсом `auth.Repository`: `store.go` (pgx) и `sqlite_store.go`
(`database/sql`). Две отдельные истории миграций: `backend/migrations/*.sql` (Postgres) и
`backend/migrations/sqlite/*.sql` (консолидированная + инкременты).
- `models.PublicUser` используется во всех публичных представлениях (участники, загрузчики,
комментарии). `User.Public()` конвертирует в него; `/auth/me` сейчас тоже отдаёт `Public()`.
- FK уже каскадные (`albums.owner_id`, `photos`, `comments`, `likes`, `tags`, `notifications`,
`share_links`, `invites` → `users` ON DELETE CASCADE).
- Блобы не в БД: удаление строк оставляет объекты, которые добирает `cmd/reconcile`.
- `scripts/seed.py` регистрирует по email.
## Goals / Non-Goals
**Goals:**
- Единый идентификатор входа (email или E.164-телефон) без отправки SMS.
- Управление профилем (имя, пароль) и удаление аккаунта со всем содержимым — на backend,
web и Android.
- Миграция без потери существующих email-only аккаунтов.
**Non-Goals:**
- Верификация телефона, смена/удаление заданных логинов, avatar-загрузка, «корзина» аккаунта.
- Мгновенная очистка блобов в транзакции удаления (остаётся на `reconcile`).
## Decisions
### 1. Схема: оба идентификатора nullable, «хотя бы один» на уровне приложения
`email` и `phone` оба `NULL`-допустимы и уникальны; обязательность «хотя бы одного»
проверяет сервис регистрации/добавления, а не `CHECK` в БД. Так существующие строки и
phone-only новые аккаунты сосуществуют одинаково в обеих СУБД, а сообщения об ошибке
остаются контролируемыми. Альтернатива с `CHECK (email IS NOT NULL OR phone IS NOT NULL)`
отвергнута: в SQLite её добавление всё равно требует rebuild, а выигрыш невелик.
### 2. E.164-нормализация в сервисном слое
Отдельная функция нормализации: обязательный ведущий `+`, только цифры, длина 8–15.
Одна точка нормализации для регистрации, добавления и входа — чтобы вход был точным
сравнением по каноническому значению. Определение типа идентификатора: наличие `@` → email,
иначе → телефон.
### 3. Добавление идентификатора — отдельный add-only эндпоинт
`POST /auth/me/identifiers` с необязательными `email` и `phone`. Сервис разрешает ровно один
новый идентификатор, отклоняет попытку задать уже существующий/второй, проверяет
уникальность и конфликты (`email_taken`, `phone_taken`). Альтернатива «`PATCH /auth/me` с
опциональными полями» отвергнута: неявное смешение изменяемого (имя) и неизменяемого
(логины) в одном ресурсе.
### 4. Смена пароля: текущий пароль + отзыв всех refresh-токенов
`POST /auth/me/password { current_password, new_password }`. При успехе `RevokeAllUserTokens`.
Клиенты очищают токены и уходят на экран входа. Access-токен (≤15 мин) формально жив, но
refresh мёртв; для консистентности клиенты всё равно разлогиниваются.
### 5. Удаление аккаунта: подтверждение паролем + удаление строки
`DELETE /auth/me { password }`. Проверяем пароль, затем `DELETE FROM users WHERE id`.
Каскад FK удаляет альбомы (вместе с доступом других участников), фото, соцданные,
уведомления, share-ссылки, приглашения и токены. После удаления любой запрос с прежним
access-токеном завершается 401, потому что строки пользователя больше нет. Возврат 204.
Блобы освобождаются `reconcile`. Альтернативы (запрет при общих альбомах, передача
владения, grace-период) отклонены по решению: удалять всё и сразу.
### 6. Приватность телефона
`phone` добавляется в `models.User`, но НЕ в `PublicUser`, поэтому не попадает в списки
участников/загрузчиков/комментариев. Собственный профиль отдаётся отдельным ответом с
телефоном, а не через `Public()`.
### 7. Миграции
- Postgres `000013_user_phone`: `ALTER TABLE users ALTER COLUMN email DROP NOT NULL`,
`ADD COLUMN phone text`, `ADD CONSTRAINT users_phone_key UNIQUE (phone)`.
- SQLite `000005_user_phone`: rebuild `users` по 12-шаговому паттерну (создать новую таблицу
без `NOT NULL` у email, с `phone`, перенести данные, пересоздать индексы), т.к. SQLite
не умеет снимать ограничения через `ALTER`.
- Существующие строки получают `phone = NULL`; email сохраняется.
### 8. Клиенты
- Web: маршрут `/profile` под `(app)`, вход из шапки по имени; login-страница — одно поле
«Email или телефон»; при регистрации поля email/phone с проверкой «хотя бы одно».
- Android: `ProfileScreen` в `GaloNavHost`, вход из `AlbumsScreen`; `AuthScreen` — одно поле
идентификатора и необязательные email/phone при регистрации; по успеху пароль/удаление →
очистка `TokenStore` → на экран auth.
- Оба клиента обновляются вместе с сервером: контракт login/register — **BREAKING**.
## Risks / Trade-offs
- [Телефон-логин без верификации → занятие чужого номера] → принято осознанно для
self-hosted; задел под OTP вне объёма.
- [SQLite rebuild `users`] → выполнять в одной миграции с временным отключением FK и
пересозданием индексов; проверить на копии `galo.db`.
- [Запросы сканируют `email` в `string`; теперь возможен NULL] → перейти на
`COALESCE(email,'')` в обоих хранилищах и покрыть тестами.
- [Гонка уникальности] → опираться на unique-индексы, маппить нарушение в `email_taken`/
`phone_taken`.
- [Удаление каскадом уничтожает общие альбомы] → необратимо и покрыто подтверждением
паролем; поведение задокументировано в спеке.
- [Access-токен живёт ≤15 мин после смены пароля/удаления] → refresh отозван, при удалении
пользователь отсутствует; клиенты разлогиниваются немедленно.
- [Значение email в JWT-claim может быть пустым] → claim не обязателен для авторизации
(используется `uid`); оставляем как есть.
## Migration Plan
1. Применить миграции: Postgres `000013`, SQLite `000005` (на копии проверить rebuild).
2. Задеплоить backend с новым контрактом.
3. Обновить web и Android синхронно (единый релиз) из-за breaking login/register.
4. Проверки: регистрация email-only, phone-only, обоими; вход обоими; добавление второго
идентификатора; смена пароля (все сессии отозваны); удаление аккаунта с общими альбомами.
5. Откат: down-миграции возвращают `email NOT NULL` и удаляют `phone`; выполним только если
нет phone-only аккаунтов. Удаление аккаунтов необратимо.
## Open Questions
Нет — все развилки (верификация, обязательность, иммутабельность, политика пароля,
поведение общих альбомов, подтверждение удаления) зафиксированы в `proposal.md` и спеках.
@@ -0,0 +1,57 @@
# Proposal
## Why
Управление аккаунтом сейчас ограничено регистрацией и входом: пользователь не может
изменить имя или пароль, а `PATCH /auth/me/preferences` умеет только настройки отображения.
Вход возможен только по email, хотя мобильный сценарий естественно подразумевает номер
телефона. Нужен явный профиль и вход по любому из двух идентификаторов.
## What Changes
- Профиль пользователя: изменение имени; смена пароля с подтверждением текущего пароля и
отзывом **всех** refresh-токенов (все сессии, включая текущую).
- `phone` добавляется в аккаунт как второй идентификатор входа, хранится в строгом формате
E.164, уникален, без подтверждения (SMS/OTP вне объёма).
- `email` перестаёт быть обязательным: обязателен **хотя бы один** из `email`/`phone`.
Оба — логины: заданный идентификатор нельзя изменить или удалить, но недостающий можно
добавить один раз (add-only).
- Вход по единому полю: `identifier` — email или E.164-телефон; определение по наличию `@`.
- **BREAKING** (API): `POST /auth/login` принимает `identifier` вместо `email`;
`POST /auth/register` принимает `phone` и допускает отсутствие `email`.
- Новые инструменты профиля на всех поверхностях: страница `/profile` в вебе и отдельный
экран в Android; поля входа/регистрации обновляются на обеих поверхностях.
- Удаление аккаунта со всем содержимым: по подтверждению текущего пароля, немедленно и
необратимо. Каскадно удаляются альбомы пользователя (включая доступ других участников),
фото, комментарии, лайки, метки, уведомления, share-ссылки и приглашения; блобы добираются
фоновым `reconcile`.
- Вне объёма: подтверждение телефона (SMS/OTP), смена/удаление заданных логинов, изменение
avatar, «корзина»/отложенное удаление аккаунта, экспорт данных перед удалением.
## Capabilities
### New Capabilities
- `user-profile`: управление аккаунтом пользователя на всех поверхностях — чтение профиля,
изменение имени, смена пароля с отзывом сессий, добавление недостающего идентификатора и
удаление аккаунта со всем содержимым; телефон виден только владельцу (email, как и прежде,
виден участникам альбома).
### Modified Capabilities
- `auth`: вход по email **или** телефону через единый `identifier`; регистрация с
требованием «хотя бы одного» из email/phone; хранение телефона в E.164.
## Impact
- Backend: `backend/internal/auth/{service.go,handlers.go,store.go,sqlite_store.go,repository.go}`,
`backend/internal/models/models.go`, `backend/internal/app/app.go` (маршруты).
- Миграции: PostgreSQL `000013_user_phone.*`, SQLite `000005_user_phone.*` (в SQLite — rebuild
`users` для снятия `NOT NULL` с email).
- Frontend: `routes/(public)/login/+page.svelte`, новая `routes/(app)/profile/+page.svelte`,
`lib/components/AppShell.svelte`, `lib/api.ts`, `lib/types.ts`, `lib/stores/auth.svelte.ts`.
- Android: `data/network/dto/Dtos.kt`, `data/network/GaloApi.kt`, `data/network/ErrorMapper.kt`,
`ui/auth/*`, новый `ui/profile/*`, `ui/GaloNavHost.kt`.
- Данные/скрипты: `scripts/seed.py` (передача phone), существующие аккаунты остаются
email-only и получают `phone = NULL`.
- Удаление: каскад FK в PostgreSQL/SQLite; очистка блобов существующим `reconcile`
(eventual, не в транзакции удаления).
- Приватность: `phone` не попадает в `PublicUser` (участники, загрузчики, комментарии).
@@ -0,0 +1,41 @@
# Spec Delta
## RENAMED Requirements
- FROM: `### Requirement: Вход по email и паролю`
- TO: `### Requirement: Вход по email или телефону`
## MODIFIED Requirements
### Requirement: Регистрация пользователя
Пользователь SHALL регистрироваться, указав имя, пароль и хотя бы один из идентификаторов —
email или телефон. Пароль SHALL храниться в виде bcrypt-хэша. Телефон SHALL нормализоваться
к формату E.164 и быть уникальным; email SHALL приводиться к нижнему регистру и быть
уникальным.
#### Scenario: Успешная регистрация
- **WHEN** пользователь отправляет свободный email, имя и пароль
- **THEN** система создаёт учётную запись и возвращает access- и refresh-токены
#### Scenario: Регистрация по телефону
- **WHEN** пользователь отправляет свободный телефон в формате E.164, имя и пароль
- **THEN** система создаёт учётную запись и возвращает access- и refresh-токены
#### Scenario: Нет ни email, ни телефона
- **WHEN** пользователь не указывает ни email, ни телефон
- **THEN** система отклоняет регистрацию
#### Scenario: Повторный email
- **WHEN** пользователь регистрируется с уже занятым email или телефоном
- **THEN** система возвращает ошибку конфликта и не создаёт вторую запись
### Requirement: Вход по email или телефону
Пользователь SHALL входить по единому идентификатору — email или телефону в формате
E.164 — и паролю.
#### Scenario: Успешный вход
- **WHEN** пользователь вводит существующий email или телефон и верный пароль
- **THEN** система возвращает access- и refresh-токены
#### Scenario: Неверные учётные данные
- **WHEN** пользователь вводит несуществующий идентификатор или неверный пароль
- **THEN** система возвращает 401, не раскрывая, какое именно поле неверно
@@ -0,0 +1,120 @@
# Spec Delta
## Purpose
Управление аккаунтом пользователя: просмотр и изменение профиля, смена пароля с отзывом
сессий, добавление второго идентификатора входа и удаление аккаунта со всем содержимым —
единообразно в веб-интерфейсе и Android-клиенте.
## ADDED Requirements
### Requirement: Просмотр собственного профиля
Пользователь SHALL получать свой профиль с именем, email, телефоном и аватаром.
Телефон SHALL быть виден только владельцу и SHALL NOT попадать в публичные представления
пользователя (участники альбома, загрузчики, комментарии).
#### Scenario: Получение профиля
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
- **THEN** ответ содержит его имя, email (если задан), телефон (если задан) и аватар
#### Scenario: Телефон не утекает
- **WHEN** пользователь просматривает участников альбома
- **THEN** представления других пользователей не содержат телефон
### Requirement: Изменение имени
Пользователь SHALL изменять своё имя. Пустое имя SHALL отклоняться. Смена имени SHALL
применяться немедленно и отражаться при следующем чтении профиля.
#### Scenario: Успешное изменение
- **WHEN** пользователь отправляет непустое имя
- **THEN** система сохраняет его и возвращает обновлённый профиль
#### Scenario: Пустое имя
- **WHEN** пользователь отправляет пустое имя
- **THEN** система отклоняет запрос и не меняет профиль
### Requirement: Смена пароля с отзывом сессий
Пользователь SHALL менять пароль, подтвердив текущий пароль. Система SHALL отклонять
неверный текущий пароль и новый пароль короче 8 символов. Успешная смена SHALL отзывать
все refresh-токены пользователя на всех устройствах.
#### Scenario: Успешная смена
- **WHEN** пользователь вводит верный текущий пароль и новый пароль допустимой длины
- **THEN** система сохраняет новый хэш и отзывает все refresh-токены пользователя
#### Scenario: Неверный текущий пароль
- **WHEN** пользователь вводит неверный текущий пароль
- **THEN** система возвращает ошибку и не меняет пароль
#### Scenario: Слабый новый пароль
- **WHEN** новый пароль короче 8 символов
- **THEN** система отклоняет запрос и сохраняет старый пароль
### Requirement: Добавление недостающего идентификатора
Пользователь с одним заданным идентификатором (email или телефон) SHALL добавить
недостающий один раз после проверки уникальности. Система SHALL отклонять добавление, когда
оба идентификатора уже заданы, а также изменение или удаление заданного. После добавления
оба идентификатора SHALL работать для входа.
#### Scenario: Добавление телефона
- **WHEN** пользователь без телефона добавляет свободный номер в формате E.164
- **THEN** система сохраняет номер, и вход по нему становится возможен
#### Scenario: Занятый идентификатор
- **WHEN** добавляемый email или телефон уже принадлежит другому пользователю
- **THEN** система возвращает ошибку конфликта и не меняет профиль
#### Scenario: Оба уже заданы
- **WHEN** у пользователя уже заданы и email, и телефон, и он пытается добавить третий
- **THEN** система отклоняет запрос
### Requirement: Удаление аккаунта со всем содержимым
Пользователь SHALL удалять свой аккаунт, подтвердив текущий пароль. Неверный пароль SHALL
отклоняться. Успешное удаление SHALL быть немедленным и необратимым: SHALL каскадно
удаляться альбомы пользователя (вместе с доступом других участников), его фото,
комментарии, лайки, метки, уведомления, share-ссылки и приглашения. Блобы SHALL
освобождаться фоновой сверкой хранилища, а не в транзакции удаления.
#### Scenario: Успешное удаление
- **WHEN** пользователь подтверждает удаление верным текущим паролем
- **THEN** учётная запись и связанное содержимое удаляются, и вход больше невозможен
#### Scenario: Общие альбомы
- **WHEN** удаляемый пользователь владеет альбомом, в котором есть другие участники
- **THEN** альбом удаляется вместе с ними
#### Scenario: Неверный пароль
- **WHEN** пользователь подтверждает удаление неверным паролем
- **THEN** система возвращает ошибку и не удаляет аккаунт
### Requirement: Страница профиля в веб-интерфейсе
Веб-приложение SHALL предоставлять отдельную страницу профиля, доступную из шапки, с
показом идентификаторов и формами изменения имени, смены пароля и удаления аккаунта.
#### Scenario: Открытие страницы
- **WHEN** аутентифицированный пользователь открывает профиль из шапки
- **THEN** отображаются его идентификаторы и формы редактирования
#### Scenario: Выход после смены пароля
- **WHEN** смена пароля завершилась успешно
- **THEN** клиент очищает токены и перенаправляет на страницу входа
#### Scenario: Выход после удаления
- **WHEN** удаление аккаунта завершилось успешно
- **THEN** клиент очищает токены и перенаправляет на страницу входа
### Requirement: Экран профиля в Android-клиенте
Android-приложение SHALL предоставлять отдельный экран профиля, доступный из списка
альбомов, с теми же возможностями, что и веб-страница.
#### Scenario: Открытие экрана
- **WHEN** пользователь открывает профиль из списка альбомов
- **THEN** отображаются идентификаторы и формы редактирования
#### Scenario: Возврат после смены пароля
- **WHEN** смена пароля завершилась успешно
- **THEN** приложение очищает токены и возвращает на экран входа
#### Scenario: Возврат после удаления
- **WHEN** удаление аккаунта завершилось успешно
- **THEN** приложение очищает токены и возвращает на экран входа
@@ -0,0 +1,51 @@
# Tasks
## 1. Модель данных и миграции
- [x] 1.1 Добавить `Phone` в `models.User` (но НЕ в `PublicUser`) — unit-тест: JSON `PublicUser` не содержит телефон
- [x] 1.2 Миграция PostgreSQL `000013_user_phone` (снять `NOT NULL` с email, добавить `phone` + unique, down-миграция) — проверить `make migrate` и откат на чистой БД
- [x] 1.3 Миграция SQLite `000005_user_phone` через rebuild `users` — проверить на копии `galo.db`, что данные сохранены, email nullable, `phone` присутствует
- [x] 1.4 Расширить миграционный тест `internal/e2e` — проверка схемы `users` (nullable email, колонка `phone`, уникальность)
## 2. Backend: вход и регистрация по идентификатору
- [x] 2.1 Нормализация E.164 и определение типа идентификатора (`@` → email) — unit-тест на валидные/невалидные номера
- [x] 2.2 Хранилища: `COALESCE(email,'')` в выборках, `GetUserByPhone` в `store.go` и `sqlite_store.go` — тесты обоих хранилищ
- [x] 2.3 `Service.Register` принимает email и/или телефон, требует «хотя бы один», ловит конфликты `email_taken`/`phone_taken` — тесты
- [x] 2.4 `Service.Login` по единому идентификатору — тесты: вход по email, по телефону, несуществующий, неверный пароль (ответ неразличим)
- [x] 2.5 Хендлеры `login` (`identifier`) и `register` (`phone` необязателен) — тесты хендлеров
## 3. Backend: профиль (имя, пароль, добавление идентификатора)
- [x] 3.1 `PATCH /auth/me` для изменения имени (пустое отклоняется) — тесты сервиса и хендлера
- [x] 3.2 `POST /auth/me/password`: проверка текущего пароля, минимум 8 символов, `RevokeAllUserTokens` при успехе — тесты
- [x] 3.3 `POST /auth/me/identifiers` (add-only): добавление недостающего email/phone, конфликты, отказ когда оба заданы — тесты
- [x] 3.4 `/auth/me` отдаёт телефон только владельцу — тест: ответ `Me` содержит phone, публичные представления — нет
## 4. Backend: удаление аккаунта
- [x] 4.1 `DELETE /auth/me` с подтверждением пароля и `DeleteUser` в обоих хранилищах — тесты: неверный пароль, успешное удаление
- [x] 4.2 Интеграционный тест: удаление владельца с общим альбомом убирает альбом у другого участника
## 5. Web-интерфейс
- [x] 5.1 Login/register: единое поле «Email или телефон», необязательные email/phone с проверкой «хотя бы одно»; обновить `api.ts`, `types.ts` — проверить `npm run check` и ручной вход
- [x] 5.2 Новая страница `(app)/profile` (имя, пароль, добавление идентификатора, удаление) и ссылка из шапки `AppShell` — проверить `npm run check` и ручной сценарий
- [x] 5.3 Очистка токенов и редирект на вход после смены пароля и удаления — ручная проверка в браузере
## 6. Android-клиент
- [x] 6.1 DTO/API: login по `identifier`, `phone` при регистрации, эндпоинты профиля; обновить `ErrorMapper` — unit-тест маппинга ошибок
- [x] 6.2 `AuthScreen`: единое поле идентификатора, необязательные email/phone — Robolectric-тест
- [x] 6.3 `ProfileScreen` + `ProfileViewModel` + запись в `GaloNavHost`, вход из списка альбомов — Robolectric-тест
- [x] 6.4 Очистка `TokenStore` и возврат на экран auth после смены пароля/удаления — Robolectric-тест
## 7. Интеграция и документация
- [x] 7.1 E2E против запущенного сервера: регистрация только по телефону, вход, добавление email, смена пароля (сессии отозваны), удаление аккаунта
- [x] 7.2 `scripts/seed.py`: поддержать необязательный `phone`; обновить README (новые эндпоинты профиля) — проверить запуск `make testdata` и чтение README
- [ ] 7.3 Полный прогон: `make test`, `cd frontend && npm run check`, `cd android && ./gradlew test`
## Workflow follow-up
- Архивировать изменение после прохождения ревью и синхронизировать основные спеки.
+23 -15
View File
@@ -12,8 +12,12 @@ otherwise they are PNG without EXIF.
Configuration: Configuration:
GALO_BASE_URL base URL of the instance (default: http://localhost:8080) GALO_BASE_URL base URL of the instance (default: http://localhost:8080)
GALO_EMAIL owner email (required) GALO_EMAIL owner email (one of GALO_EMAIL/GALO_PHONE required)
GALO_PHONE owner phone, E.164 (one of GALO_EMAIL/GALO_PHONE required)
GALO_PASSWORD owner password (required) GALO_PASSWORD owner password (required)
Login uses the single-identifier contract: the email is preferred, otherwise the
phone is used.
""" """
from __future__ import annotations from __future__ import annotations
@@ -301,9 +305,11 @@ class Stats:
) )
def login(client, email, password): def login(client, identifier, password):
resp = client.request( resp = client.request(
"POST", "/api/auth/login", json_body={"email": email, "password": password} "POST",
"/api/auth/login",
json_body={"identifier": identifier, "password": password},
) )
client.token = resp["tokens"]["access_token"] client.token = resp["tokens"]["access_token"]
@@ -408,7 +414,7 @@ def build_plan(args):
return "\n".join(lines) return "\n".join(lines)
def run(args, base_url, email, password): def run(args, base_url, identifier, password):
if args.dry_run: if args.dry_run:
print(build_plan(args)) print(build_plan(args))
return 0 return 0
@@ -418,8 +424,8 @@ def run(args, base_url, email, password):
client = Client(base_url, timeout=args.timeout) client = Client(base_url, timeout=args.timeout)
stats = Stats() stats = Stats()
login(client, email, password) login(client, identifier, password)
print("Вошел как {} @ {}".format(email, base_url)) print("Вошел как {} @ {}".format(identifier, base_url))
if args.video and not HAVE_FFMPEG: if args.video and not HAVE_FFMPEG:
print( print(
@@ -516,26 +522,28 @@ def main(argv=None):
args = parse_args(argv) args = parse_args(argv)
base_url = os.environ.get("GALO_BASE_URL", DEFAULT_BASE_URL) base_url = os.environ.get("GALO_BASE_URL", DEFAULT_BASE_URL)
email = os.environ.get("GALO_EMAIL") email = os.environ.get("GALO_EMAIL")
phone = os.environ.get("GALO_PHONE")
password = os.environ.get("GALO_PASSWORD") password = os.environ.get("GALO_PASSWORD")
identifier = email or phone
if not args.dry_run: if not args.dry_run:
missing = [ if not identifier:
name
for name, value in (("GALO_EMAIL", email), ("GALO_PASSWORD", password))
if not value
]
if missing:
print( print(
"Не заданы переменные окружения: {}".format(", ".join(missing)), "Не задан идентификатор входа: укажите GALO_EMAIL или GALO_PHONE",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2
if not password:
print("Не задана переменная окружения: GALO_PASSWORD", file=sys.stderr)
return 2
try: try:
return run(args, base_url, email or "", password or "") return run(args, base_url, identifier or "", password or "")
except ApiError as exc: except ApiError as exc:
if exc.status == 401: if exc.status == 401:
message = "Ошибка аутентификации: проверьте GALO_EMAIL и GALO_PASSWORD" message = (
"Ошибка аутентификации: проверьте GALO_EMAIL/GALO_PHONE и GALO_PASSWORD"
)
else: else:
message = "Ошибка API: {}".format(exc) message = "Ошибка API: {}".format(exc)
print(message, file=sys.stderr) print(message, file=sys.stderr)