Feature: Settings screen with account, theme, profile, reset + credits

Backend:
- New GET/PUT /api/me endpoint to read and update the authenticated
  user's profile (currently display_name). UserStore.UpdateDisplayName.
- Wired into the authed router.

App:
- New SettingsScreen + SettingsViewModel with five sections:
  * Account & Sync: login status, email, server URL, logout, connect.
  * Profile: edit display name (pushed to PUT /api/me when logged in).
  * Appearance: System / Light / Dark theme switch, persisted in
    SessionManager and applied via MitbringslTheme(sessionManager).
  * Data: 'Reset local data' wipes Room tables (server data kept).
  * About: version, 'Developed by Janik Dietz', and credits to
    GLM-5.2 + Gemini 3.6 Flash.
- Theme.kt now reads the user's theme preference (StateFlow) instead
  of only the system default; MainActivity passes SessionManager in.
- Navigation: new SettingsNavKey; settings gear icon in ListsScreen
  top bar; Settings links back to the Sync/Account screen.
- DTOs/API: UpdateMeRequestDto + getMe()/updateMe() for /api/me.
This commit is contained in:
Tronax 2026-08-06 10:39:12 +02:00
parent 3f187f1ede
commit 729865be78
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
13 changed files with 717 additions and 12 deletions

View file

@ -28,7 +28,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
MitbringslTheme {
MitbringslTheme(sessionManager = sessionManager) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background

View file

@ -8,6 +8,7 @@ import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.ui.auth.AuthScreen
import com.example.mitbringsl.ui.detail.ListDetailScreen
import com.example.mitbringsl.ui.lists.ListsScreen
import com.example.mitbringsl.ui.settings.SettingsScreen
@Composable
fun MainNavigation(sessionManager: SessionManager) {
@ -25,6 +26,9 @@ fun MainNavigation(sessionManager: SessionManager) {
},
onOpenSync = {
backStack.add(AuthNavKey)
},
onOpenSettings = {
backStack.add(SettingsNavKey)
}
)
}
@ -35,6 +39,14 @@ fun MainNavigation(sessionManager: SessionManager) {
}
)
}
entry<SettingsNavKey> {
SettingsScreen(
onBack = { backStack.removeLastOrNull() },
onOpenSync = {
backStack.add(AuthNavKey)
}
)
}
entry<ListDetailNavKey> { key ->
ListDetailScreen(
listId = key.listId,

View file

@ -5,4 +5,5 @@ import kotlinx.serialization.Serializable
@Serializable data object AuthNavKey : NavKey
@Serializable data object ListsNavKey : NavKey
@Serializable data object SettingsNavKey : NavKey
@Serializable data class ListDetailNavKey(val listId: String) : NavKey

View file

@ -29,6 +29,17 @@ class SessionManager @Inject constructor(
private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
// Theme preference: "system" | "light" | "dark"
private val _themeMode = MutableStateFlow(getThemeMode())
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
fun getThemeMode(): String = prefs.getString("theme_mode", null) ?: "system"
fun saveThemeMode(mode: String) {
prefs.edit { putString("theme_mode", mode) }
_themeMode.value = mode
}
fun getServerUrl(): String {
return prefs.getString("server_url", null) ?: BuildConfig.BASE_URL
}

View file

@ -27,6 +27,14 @@ interface MitbringslApi {
@POST("auth/logout")
suspend fun logout(): Response<Unit>
// --- Profile (/api/me) --------------------------------------------------
@GET("api/me")
suspend fun getMe(): Response<UserDto>
@PUT("api/me")
suspend fun updateMe(@Body body: UpdateMeRequestDto): Response<UserDto>
// --- Lists --------------------------------------------------------------
@GET("api/lists")

View file

@ -41,6 +41,11 @@ data class UserDto(
@SerialName("display_name") val displayName: String = "",
)
@Serializable
data class UpdateMeRequestDto(
@SerialName("display_name") val displayName: String? = null,
)
// ---------------------------------------------------------------------------
// Lists
// ---------------------------------------------------------------------------

View file

@ -8,7 +8,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import com.example.mitbringsl.data.auth.SessionManager
private val DarkColorScheme = darkColorScheme(primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80)
@ -17,25 +20,27 @@ private val LightColorScheme =
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
/**
* App theme. The dark/light mode is driven by the user's preference stored in
* [SessionManager] ("system" | "light" | "dark"), defaulting to the system setting.
*/
@Composable
fun MitbringslTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
sessionManager: SessionManager,
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val mode by sessionManager.themeMode.collectAsState()
val systemDark = isSystemInDarkTheme()
val darkTheme = when (mode) {
"light" -> false
"dark" -> true
else -> systemDark // "system" or any unknown value
}
val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {

View file

@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.*
@ -33,6 +34,7 @@ import com.example.mitbringsl.data.local.entity.ListEntity
fun ListsScreen(
onSelectList: (String) -> Unit,
onOpenSync: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(),
) {
@ -56,6 +58,12 @@ fun ListsScreen(
)
},
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Einstellungen"
)
}
IconButton(onClick = {
if (isLoggedIn) {
showJoinDialog = true

View file

@ -0,0 +1,410 @@
package com.example.mitbringsl.ui.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Brightness6
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onOpenSync: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle()
val currentServerUrl by viewModel.sessionManager.serverUrl.collectAsStateWithLifecycle()
val userEmail = viewModel.sessionManager.getUserEmail()
val themeMode by viewModel.sessionManager.themeMode.collectAsStateWithLifecycle()
var showResetDialog by remember { mutableStateOf(false) }
var showLogoutDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Einstellungen", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
modifier = modifier
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// --- Account / Sync -------------------------------------------------
SectionHeader(text = "Account & Sync")
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
if (isLoggedIn) {
ProfileRow(label = "Angemeldet als", value = userEmail ?: "")
ProfileRow(
label = "Anzeigename",
value = uiState.serverDisplayName.ifBlank { "" }
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onOpenSync,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Icon(imageVector = Icons.Default.Dns, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Server-Verbindung verwalten")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { showLogoutDialog = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(imageVector = Icons.Default.CloudOff, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Abmelden")
}
} else {
Text(
text = "Nicht verbunden",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Server: $currentServerUrl",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onOpenSync,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Text("Mit Server verbinden")
}
}
}
}
// --- Profil (Display Name) -----------------------------------------
if (isLoggedIn) {
SectionHeader(text = "Profil")
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = uiState.displayNameInput,
onValueChange = viewModel::onDisplayNameChanged,
label = { Text("Anzeigename") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isSaving
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = viewModel::saveDisplayName,
enabled = !uiState.isSaving && uiState.displayNameInput != uiState.serverDisplayName,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
if (uiState.isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp
)
} else {
Icon(imageVector = Icons.Default.Person, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Speichern")
}
}
}
}
}
// --- Theme ---------------------------------------------------------
SectionHeader(text = "Erscheinungsbild")
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = Icons.Default.Brightness6, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text("Design", style = MaterialTheme.typography.bodyLarge)
}
Spacer(modifier = Modifier.height(12.dp))
ThemeOptionRow(
label = "System",
selected = themeMode == "system",
onClick = { viewModel.setThemeMode("system") }
)
ThemeOptionRow(
label = "Hell",
selected = themeMode == "light",
onClick = { viewModel.setThemeMode("light") }
)
ThemeOptionRow(
label = "Dunkel",
selected = themeMode == "dark",
onClick = { viewModel.setThemeMode("dark") }
)
}
}
// --- Daten zurücksetzen --------------------------------------------
SectionHeader(text = "Daten")
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Lokale Daten löschen",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Entfernt alle Listen und Items von diesem Gerät. Server-Daten bleiben erhalten und können neu synchronisiert werden.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = { showResetDialog = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(imageVector = Icons.Default.DeleteForever, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Lokale Daten zurücksetzen")
}
}
}
// --- Über / Credits ------------------------------------------------
SectionHeader(text = "Über Mitbringsl")
Card(shape = RoundedCornerShape(16.dp)) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Mitbringsl",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Version 1.0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Entwickelt von",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Janik Dietz",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Mit Unterstützung durch",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
CreditItem(text = "GLM-5.2")
CreditItem(text = "Gemini 3.6 Flash")
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Local-First Einkaufslisten-App werbefrei, selbstgehostet.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
// --- message / error toast-like ------------------------------------
if (uiState.message != null) {
Snackbar { Text(uiState.message!!) }
}
if (uiState.error != null) {
Snackbar(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
) { Text(uiState.error!!) }
}
Spacer(modifier = Modifier.height(24.dp))
}
}
// --- dialogs -------------------------------------------------------------
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Lokale Daten löschen?") },
text = {
Text(
"Alle Listen und Items werden von diesem Gerät entfernt. " +
"Diese Aktion kann nicht rückgängig gemacht werden. " +
"Auf dem Server gespeicherte Daten bleiben erhalten."
)
},
confirmButton = {
Button(
onClick = {
viewModel.resetLocalData()
showResetDialog = false
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
shape = RoundedCornerShape(12.dp)
) {
Text("Löschen")
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) { Text("Abbrechen") }
},
shape = RoundedCornerShape(24.dp)
)
}
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Abmelden?") },
text = {
Text(
"Du wirst von diesem Gerät abgemeldet. " +
"Lokale Änderungen, die noch nicht synchronisiert wurden, gehen verloren."
)
},
confirmButton = {
Button(
onClick = {
viewModel.logout()
showLogoutDialog = false
},
shape = RoundedCornerShape(12.dp)
) {
Text("Abmelden")
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) { Text("Abbrechen") }
},
shape = RoundedCornerShape(24.dp)
)
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
}
@Composable
private fun ProfileRow(label: String, value: String) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun ThemeOptionRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = selected, onClick = onClick)
Spacer(modifier = Modifier.width(8.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun CreditItem(text: String) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f),
shape = RoundedCornerShape(20.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
)
}
}

View file

@ -0,0 +1,144 @@
package com.example.mitbringsl.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.mitbringsl.data.auth.AuthRepository
import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.data.local.AppDatabase
import com.example.mitbringsl.data.remote.api.MitbringslApi
import com.example.mitbringsl.data.remote.dto.UpdateMeRequestDto
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsUiState(
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val displayNameInput: String = "",
val serverDisplayName: String = "",
val message: String? = null,
val error: String? = null,
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val api: MitbringslApi,
private val authRepository: AuthRepository,
private val db: AppDatabase,
val sessionManager: SessionManager,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
loadProfile()
}
/** Loads the display name from the server (if logged in) or falls back to local. */
fun loadProfile() {
if (!sessionManager.isLoggedIn.value) {
_uiState.update { it.copy(displayNameInput = "", serverDisplayName = "") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val resp = api.getMe()
if (resp.isSuccessful && resp.body() != null) {
val name = resp.body()!!.displayName
_uiState.update {
it.copy(
isLoading = false,
displayNameInput = name,
serverDisplayName = name,
)
}
} else {
_uiState.update {
it.copy(isLoading = false, error = "Profil konnte nicht geladen werden.")
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, error = e.localizedMessage ?: "Netzwerkfehler")
}
}
}
}
fun onDisplayNameChanged(value: String) {
_uiState.update { it.copy(displayNameInput = value, message = null, error = null) }
}
/** Pushes the display name to the server (only when logged in). */
fun saveDisplayName() {
if (!sessionManager.isLoggedIn.value) {
_uiState.update { it.copy(message = "Nicht verbunden Änderung nur lokal möglich.") }
return
}
val name = _uiState.value.displayNameInput.trim()
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, error = null, message = null) }
try {
val resp = api.updateMe(UpdateMeRequestDto(displayName = name))
if (resp.isSuccessful && resp.body() != null) {
_uiState.update {
it.copy(
isSaving = false,
serverDisplayName = resp.body()!!.displayName,
message = "Anzeigename gespeichert.",
)
}
} else {
_uiState.update {
it.copy(isSaving = false, error = "Speichern fehlgeschlagen (HTTP ${resp.code()}).")
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isSaving = false, error = e.localizedMessage ?: "Netzwerkfehler")
}
}
}
}
fun setThemeMode(mode: String) {
sessionManager.saveThemeMode(mode)
}
/** Logs the user out. */
fun logout() {
viewModelScope.launch {
authRepository.logout()
}
}
/**
* Wipes all local data (lists, items, op_log) the destructive reset.
* Server data is NOT affected; the user can re-sync after logging in again.
*/
fun resetLocalData() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
db.clearAllTables()
_uiState.update {
it.copy(isLoading = false, message = "Lokale Daten wurden gelöscht.")
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, error = e.localizedMessage ?: "Löschen fehlgeschlagen.")
}
}
}
}
fun clearMessage() {
_uiState.update { it.copy(message = null, error = null) }
}
}

View file

@ -114,6 +114,27 @@ func (s *UserStore) GetByOIDCSubject(ctx context.Context, issuer, subject string
return u, nil
}
// UpdateDisplayName sets the display_name of the user with the given id.
// An empty displayName clears the field (stores NULL).
func (s *UserStore) UpdateDisplayName(ctx context.Context, id uuid.UUID, displayName string) (User, error) {
const q = `
UPDATE users SET display_name = NULLIF($2, ''), updated_at = now()
WHERE id = $1
RETURNING id, email, password_hash, oidc_subject, oidc_issuer, display_name`
var u User
var dn *string
err := s.pool.QueryRow(ctx, q, id, displayName).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrUserNotFound
}
return User{}, fmt.Errorf("update display name: %w", err)
}
u.DisplayName = dn
return u, nil
}
// CreateOIDCUser inserts a new user for an OIDC login. The user has no password
// (password_hash is NULL) and is identified by (issuer, subject). email may be
// empty if the IdP did not provide one; we store a synthesized placeholder so

View file

@ -21,6 +21,7 @@ type API struct {
ops *OpsHandler
suggest *SuggestHandler
config *ConfigHandler
me *MeHandler
}
// NewAPI constructs the API with all handler groups.
@ -51,6 +52,7 @@ func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API {
ops: NewOpsHandler(opStore, listStore),
suggest: NewSuggestHandler(suggestStore),
config: NewConfigHandler(cfg),
me: NewMeHandler(users),
}
}
@ -85,6 +87,8 @@ func (a *API) Handler() http.Handler {
mux.HandleFunc("POST /auth/logout", a.auth.Logout)
// --- authenticated API endpoints ---
mux.Handle("GET /api/me", a.RequireAuth(http.HandlerFunc(a.me.Get)))
mux.Handle("PUT /api/me", a.RequireAuth(http.HandlerFunc(a.me.Update)))
mux.Handle("GET /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.List)))
mux.Handle("POST /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.Create)))
mux.Handle("POST /api/lists/join", a.RequireAuth(http.HandlerFunc(a.lists.Join)))

View file

@ -0,0 +1,76 @@
package httpapi
import (
"log/slog"
"net/http"
"strings"
"github.com/mitbringsl/backend/internal/auth"
)
// MeHandler exposes the authenticated user's own profile (/api/me).
type MeHandler struct {
users *auth.UserStore
}
// NewMeHandler constructs a MeHandler.
func NewMeHandler(users *auth.UserStore) *MeHandler {
return &MeHandler{users: users}
}
type updateMeRequest struct {
DisplayName *string `json:"display_name"` // nil = unchanged, "" = clear
}
// Get returns the current user's profile.
// GET /api/me
func (h *MeHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromCtx(r)
if !ok {
renderError(w, http.StatusUnauthorized, "Unauthorized", "Missing user context.")
return
}
u, err := h.users.GetUserByID(r.Context(), userID)
if err != nil {
slog.Error("get me failed", "error", err, "user_id", userID)
renderError(w, http.StatusInternalServerError, "Internal error", "Could not load user.")
return
}
renderJSON(w, http.StatusOK, toUserDTO(u))
}
// Update changes editable fields of the current user (currently display_name).
// PUT /api/me
func (h *MeHandler) Update(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromCtx(r)
if !ok {
renderError(w, http.StatusUnauthorized, "Unauthorized", "Missing user context.")
return
}
var req updateMeRequest
if !decodeJSON(w, r, &req) {
return
}
// Only update when display_name is provided in the body (pointer non-nil).
// Trimming to keep stored names tidy; an empty string clears the field.
if req.DisplayName == nil {
// Nothing to change return the current profile.
u, err := h.users.GetUserByID(r.Context(), userID)
if err != nil {
renderError(w, http.StatusInternalServerError, "Internal error", "Could not load user.")
return
}
renderJSON(w, http.StatusOK, toUserDTO(u))
return
}
name := strings.TrimSpace(*req.DisplayName)
u, err := h.users.UpdateDisplayName(r.Context(), userID, name)
if err != nil {
slog.Error("update me failed", "error", err, "user_id", userID)
renderError(w, http.StatusInternalServerError, "Internal error", "Could not update user.")
return
}
renderJSON(w, http.StatusOK, toUserDTO(u))
}