diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ab028..4ceab74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **List alias** — Optional human-friendly alias (e.g. `groceries`) for shopping lists, usable in place of the UUID; aliases are unique among active lists, normalized to lowercase, and deleted automatically when the list expires. New endpoints: `GET /api/lists/by-alias/{alias}` to resolve an alias, `PUT /api/lists/{id}/alias` to set, update, or remove it. Small settings UI in the burger menu for managing the alias per list. - **AGENTS.md** — AI coding agent instructions covering project overview, tech stack, exact commands, coding conventions, development lifecycle (branch-per-change, Conventional Commits, CHANGELOG discipline), PR workflow, and three-tier boundaries (always/ask/never) +### Changed +- **Database schema initialization** — Switched from `SchemaUtils.create()` to `SchemaUtils.createMissingTablesAndColumns()` so new columns (like alias) are automatically added to existing tables on deployment without manual migration + ### Fixed - **Flaky backend tests** — Resolved intermittent `PSQLException: Connection refused` errors in CI by fixing Testcontainers lifecycle mismatch (`@Container` moved to `companion object` for all `@TestInstance(PER_CLASS)` test classes) and disabling parallel test-class execution (`maxParallelForks = 1`) to prevent Exposed's global database registry from being overwritten mid-test by a concurrently running test class diff --git a/README.md b/README.md index 1c9c0f3..e8055b8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A real-time collaborative shopping list app. Create a list, share the link, and - **Dark Mode** — Light, dark, and system-preference theme toggle - **Smart Autocomplete** — Suggests items from your cross-list history as you type - **Push Notifications** — Opt-in Web Push alerts when list changes happen +- **List Aliases** — Optionally assign a human-friendly alias (e.g. `groceries`) to any list, usable in place of the UUID - **Prometheus Metrics** — Application metrics (online users, list count, JVM, HTTP requests) exposed for Prometheus scraping ## Tech Stack @@ -134,6 +135,8 @@ For production deployment, see [DEPLOYMENT.md](DEPLOYMENT.md). | `PUT` | `/api/lists/{id}/items/{itemId}` | Update an item | | `DELETE` | `/api/lists/{id}/items/{itemId}` | Delete an item | | `POST` | `/api/lists/{id}/clear-completed` | Remove completed items | +| `GET` | `/api/lists/by-alias/{alias}` | Resolve a list by its alias | +| `PUT` | `/api/lists/{id}/alias` | Set, update, or remove a list's alias | | `WS` | `/ws/{listId}` | Real-time WebSocket connection | | `GET` | `/api/push/vapid-key` | Get the server's VAPID public key | | `POST` | `/api/push/subscribe` | Subscribe to push notifications | @@ -145,7 +148,7 @@ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before ## Security -This app has no authentication — lists are accessible to anyone who knows the UUID. See [SECURITY.md](SECURITY.md) for deployment guidance and how to report vulnerabilities. +This app has no authentication — lists are accessible to anyone who knows the UUID or alias. See [SECURITY.md](SECURITY.md) for deployment guidance and how to report vulnerabilities. ## License diff --git a/backend/src/main/kotlin/com/shoppinglist/database/DatabaseFactory.kt b/backend/src/main/kotlin/com/shoppinglist/database/DatabaseFactory.kt index 14a07cd..1f43eb3 100644 --- a/backend/src/main/kotlin/com/shoppinglist/database/DatabaseFactory.kt +++ b/backend/src/main/kotlin/com/shoppinglist/database/DatabaseFactory.kt @@ -12,7 +12,7 @@ object DatabaseFactory { val database = Database.connect(createHikariDataSource()) transaction(database) { - SchemaUtils.create(ShoppingLists, ListItems, PushSubscriptions) + SchemaUtils.createMissingTablesAndColumns(ShoppingLists, ListItems, PushSubscriptions) } } diff --git a/backend/src/main/kotlin/com/shoppinglist/database/Tables.kt b/backend/src/main/kotlin/com/shoppinglist/database/Tables.kt index b54e545..e0168c0 100644 --- a/backend/src/main/kotlin/com/shoppinglist/database/Tables.kt +++ b/backend/src/main/kotlin/com/shoppinglist/database/Tables.kt @@ -7,6 +7,7 @@ import java.time.Instant object ShoppingLists : UUIDTable("shopping_lists") { val createdAt = timestamp("created_at").default(Instant.now()) val lastModified = timestamp("last_modified").default(Instant.now()) + val alias = varchar("alias", 64).nullable().uniqueIndex("uq_shopping_lists_alias") } object ListItems : UUIDTable("list_items") { diff --git a/backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt b/backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt index bf8c487..111c072 100644 --- a/backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt +++ b/backend/src/main/kotlin/com/shoppinglist/models/ShoppingList.kt @@ -9,7 +9,8 @@ data class ShoppingList( val items: List = emptyList(), val createdAt: String, val lastModified: String, - val expiresAt: String? = null + val expiresAt: String? = null, + val alias: String? = null ) @Serializable @@ -23,5 +24,11 @@ data class ShoppingListResponse( val items: List, val createdAt: String, val lastModified: String, - val expiresAt: String? = null -) \ No newline at end of file + val expiresAt: String? = null, + val alias: String? = null +) + +@Serializable +data class UpdateAliasRequest( + val alias: String? = null +) diff --git a/backend/src/main/kotlin/com/shoppinglist/repository/ListRepository.kt b/backend/src/main/kotlin/com/shoppinglist/repository/ListRepository.kt index 25770b4..908d4e5 100644 --- a/backend/src/main/kotlin/com/shoppinglist/repository/ListRepository.kt +++ b/backend/src/main/kotlin/com/shoppinglist/repository/ListRepository.kt @@ -10,4 +10,7 @@ interface ListRepository { suspend fun deleteList(id: UUID): Boolean suspend fun listExists(id: UUID): Boolean suspend fun getListWithExpiration(id: UUID): ShoppingList? -} \ No newline at end of file + suspend fun getListByAlias(alias: String): ShoppingList? + suspend fun updateAlias(id: UUID, alias: String?): Boolean + suspend fun aliasExists(alias: String): Boolean +} diff --git a/backend/src/main/kotlin/com/shoppinglist/repository/ListRepositoryImpl.kt b/backend/src/main/kotlin/com/shoppinglist/repository/ListRepositoryImpl.kt index 9e3c6da..2d90e1e 100644 --- a/backend/src/main/kotlin/com/shoppinglist/repository/ListRepositoryImpl.kt +++ b/backend/src/main/kotlin/com/shoppinglist/repository/ListRepositoryImpl.kt @@ -51,7 +51,8 @@ class ListRepositoryImpl : ListRepository { id = listRow[ShoppingLists.id].toString(), items = items, createdAt = listRow[ShoppingLists.createdAt].toString(), - lastModified = listRow[ShoppingLists.lastModified].toString() + lastModified = listRow[ShoppingLists.lastModified].toString(), + alias = listRow[ShoppingLists.alias] ) } @@ -100,7 +101,59 @@ class ListRepositoryImpl : ListRepository { items = items, createdAt = listRow[ShoppingLists.createdAt].toString(), lastModified = lastModified.toString(), - expiresAt = expiresAt.toString() + expiresAt = expiresAt.toString(), + alias = listRow[ShoppingLists.alias] ) } -} \ No newline at end of file + + override suspend fun getListByAlias(alias: String): ShoppingList? = newSuspendedTransaction { + val normalizedAlias = alias.trim().lowercase() + + val listRow = ShoppingLists.select { ShoppingLists.alias eq normalizedAlias }.singleOrNull() + ?: return@newSuspendedTransaction null + + val listId = listRow[ShoppingLists.id].value + + val items = ListItems + .select { ListItems.listId eq listId } + .orderBy(ListItems.itemOrder) + .map { row -> + ListItem( + id = row[ListItems.id].toString(), + text = row[ListItems.text], + completed = row[ListItems.completed], + createdAt = row[ListItems.createdAt].toString(), + order = row[ListItems.itemOrder] + ) + } + + val retentionDays = System.getenv("LIST_RETENTION_DAYS")?.toLongOrNull() + ?: System.getProperty("LIST_RETENTION_DAYS")?.toLongOrNull() + ?: 30L + val lastModified = listRow[ShoppingLists.lastModified] + val expiresAt = lastModified.plus(retentionDays, ChronoUnit.DAYS) + + ShoppingList( + id = listRow[ShoppingLists.id].toString(), + items = items, + createdAt = listRow[ShoppingLists.createdAt].toString(), + lastModified = lastModified.toString(), + expiresAt = expiresAt.toString(), + alias = listRow[ShoppingLists.alias] + ) + } + + override suspend fun updateAlias(id: UUID, alias: String?): Boolean = newSuspendedTransaction { + val normalizedAlias = alias?.trim()?.lowercase() + + val updatedRows = ShoppingLists.update({ ShoppingLists.id eq id }) { + it[ShoppingLists.alias] = normalizedAlias + } + updatedRows > 0 + } + + override suspend fun aliasExists(alias: String): Boolean = newSuspendedTransaction { + val normalizedAlias = alias.trim().lowercase() + ShoppingLists.select { ShoppingLists.alias eq normalizedAlias }.count() > 0 + } +} diff --git a/backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt b/backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt index bd956e7..0e811af 100644 --- a/backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt +++ b/backend/src/main/kotlin/com/shoppinglist/routes/ListRoutes.kt @@ -427,5 +427,131 @@ fun Route.listRoutes(pushNotificationService: PushNotificationService? = null) { ) } } + + // GET /api/lists/by-alias/{alias} - Resolve alias to list + get("/by-alias/{alias}") { + try { + val aliasParam = call.parameters["alias"] + if (aliasParam.isNullOrBlank()) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("missing_alias", "Alias is required") + ) + return@get + } + + val normalizedAlias = aliasParam.trim().lowercase() + + val list = listRepository.getListByAlias(normalizedAlias) + if (list == null) { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("list_not_found", "No list found with this alias") + ) + return@get + } + + call.respond(HttpStatusCode.OK, list) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("server_error", "Failed to resolve alias") + ) + } + } + + // PUT /api/lists/{id}/alias - Set/update/remove alias + put("/{id}/alias") { + try { + val idParam = call.parameters["id"] + if (idParam == null) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("missing_id", "List ID is required") + ) + return@put + } + + val listId = try { + UUID.fromString(idParam) + } catch (e: IllegalArgumentException) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("invalid_id", "Invalid UUID format for list ID") + ) + return@put + } + + if (!listRepository.listExists(listId)) { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("list_not_found", "Shopping list not found") + ) + return@put + } + + val request = try { + call.receive() + } catch (e: Exception) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("invalid_request", "Invalid request body") + ) + return@put + } + + val normalizedAlias = request.alias?.trim()?.lowercase() + + if (normalizedAlias != null) { + val trimmedInput = request.alias!!.trim() + val aliasRegex = Regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?\$") + if (!aliasRegex.matches(trimmedInput) || trimmedInput.length > 64) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("invalid_alias", "Alias must be 1-64 characters, lowercase alphanumeric and hyphens only, cannot start or end with a hyphen") + ) + return@put + } + + val uuidRegex = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\$") + if (uuidRegex.matches(normalizedAlias)) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("invalid_alias", "Alias cannot be in UUID format") + ) + return@put + } + + if (listRepository.aliasExists(normalizedAlias)) { + val currentList = listRepository.getListWithExpiration(listId) + if (currentList?.alias != normalizedAlias) { + call.respond( + HttpStatusCode.Conflict, + ErrorResponse("alias_taken", "This alias is already in use by another list") + ) + return@put + } + } + } + + listRepository.updateAlias(listId, normalizedAlias) + + val updatedList = listRepository.getListWithExpiration(listId) + if (updatedList == null) { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("list_not_found", "Shopping list not found") + ) + return@put + } + + call.respond(HttpStatusCode.OK, updatedList) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("server_error", "Failed to update alias") + ) + } + } } } \ No newline at end of file diff --git a/backend/src/test/kotlin/com/shoppinglist/repository/ListRepositoryTest.kt b/backend/src/test/kotlin/com/shoppinglist/repository/ListRepositoryTest.kt index 150183e..58252ff 100644 --- a/backend/src/test/kotlin/com/shoppinglist/repository/ListRepositoryTest.kt +++ b/backend/src/test/kotlin/com/shoppinglist/repository/ListRepositoryTest.kt @@ -19,6 +19,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.assertFalse @Testcontainers @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -137,4 +138,54 @@ class ListRepositoryTest { assertTrue(deleted) assertNull(retrievedList) } + + @Test + fun `getListByAlias should return list with matching alias`() = runBlocking { + val listId = UUID.randomUUID() + listRepository.createList(listId) + listRepository.updateAlias(listId, "test-alias") + + val retrievedList = listRepository.getListByAlias("test-alias") + + assertNotNull(retrievedList) + assertEquals(listId.toString(), retrievedList.id) + assertEquals("test-alias", retrievedList.alias) + } + + @Test + fun `getListByAlias should return null for non-existing alias`() = runBlocking { + val retrievedList = listRepository.getListByAlias("nonexistent") + + assertNull(retrievedList) + } + + @Test + fun `updateAlias should set and clear alias`() = runBlocking { + val listId = UUID.randomUUID() + listRepository.createList(listId) + + val setResult = listRepository.updateAlias(listId, "my-alias") + assertTrue(setResult) + + val listWithAlias = listRepository.getListById(listId) + assertNotNull(listWithAlias) + assertEquals("my-alias", listWithAlias.alias) + + val clearResult = listRepository.updateAlias(listId, null) + assertTrue(clearResult) + + val listWithoutAlias = listRepository.getListById(listId) + assertNotNull(listWithoutAlias) + assertNull(listWithoutAlias.alias) + } + + @Test + fun `aliasExists should return true for existing alias`() = runBlocking { + val listId = UUID.randomUUID() + listRepository.createList(listId) + listRepository.updateAlias(listId, "existing-alias") + + assertTrue(listRepository.aliasExists("existing-alias")) + assertFalse(listRepository.aliasExists("nonexistent-alias")) + } } \ No newline at end of file diff --git a/backend/src/test/kotlin/com/shoppinglist/routes/ListRoutesTest.kt b/backend/src/test/kotlin/com/shoppinglist/routes/ListRoutesTest.kt index 60e6b29..773bf04 100644 --- a/backend/src/test/kotlin/com/shoppinglist/routes/ListRoutesTest.kt +++ b/backend/src/test/kotlin/com/shoppinglist/routes/ListRoutesTest.kt @@ -22,6 +22,7 @@ import org.testcontainers.junit.jupiter.Testcontainers import java.util.* import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @Testcontainers @@ -534,4 +535,183 @@ class ListRoutesTest { assertEquals("Item 1", retrievedList.items[0].text) assertEquals("Item 2", retrievedList.items[1].text) } + + @Test + fun `PUT api lists id alias should set alias on list`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + val aliasResponse = client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "my-groceries"}""") + } + + assertEquals(HttpStatusCode.OK, aliasResponse.status) + val updatedList = json.decodeFromString(aliasResponse.bodyAsText()) + assertEquals("my-groceries", updatedList.alias) + } + + @Test + fun `PUT api lists id alias should reject invalid alias format`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + val invalidAliases = listOf("has spaces", "UPPER", "special!char", "-starts-with-hyphen", "ends-with-hyphen-") + for (invalidAlias in invalidAliases) { + val response = client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "$invalidAlias"}""") + } + assertEquals(HttpStatusCode.BadRequest, response.status, "Expected 400 for alias: $invalidAlias") + } + } + + @Test + fun `PUT api lists id alias should reject duplicate alias`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val list1Response = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val list1 = json.decodeFromString(list1Response.bodyAsText()) + + val list2Response = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val list2 = json.decodeFromString(list2Response.bodyAsText()) + + client.put("/api/lists/${list1.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "shared-alias"}""") + } + + val conflictResponse = client.put("/api/lists/${list2.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "shared-alias"}""") + } + + assertEquals(HttpStatusCode.Conflict, conflictResponse.status) + val responseBody = conflictResponse.bodyAsText() + assertTrue(responseBody.contains("alias_taken")) + } + + @Test + fun `PUT api lists id alias should allow removing alias`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "temp-alias"}""") + } + + val removeResponse = client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": null}""") + } + + assertEquals(HttpStatusCode.OK, removeResponse.status) + val updatedList = json.decodeFromString(removeResponse.bodyAsText()) + assertNull(updatedList.alias) + } + + @Test + fun `GET api lists by-alias alias should resolve alias to list`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "weekly-shop"}""") + } + + val resolveResponse = client.get("/api/lists/by-alias/weekly-shop") + + assertEquals(HttpStatusCode.OK, resolveResponse.status) + val resolvedList = json.decodeFromString(resolveResponse.bodyAsText()) + assertEquals(createdList.id, resolvedList.id) + assertEquals("weekly-shop", resolvedList.alias) + } + + @Test + fun `GET api lists by-alias alias should return 404 for unknown alias`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val response = client.get("/api/lists/by-alias/nonexistent") + + assertEquals(HttpStatusCode.NotFound, response.status) + val responseBody = response.bodyAsText() + assertTrue(responseBody.contains("No list found with this alias")) + } + + @Test + fun `PUT api lists id alias should reject UUID-format aliases`() = testApplication { + application { + configureSerialization() + configureCORS() + configureRouting() + } + + val createResponse = client.post("/api/lists") { + contentType(ContentType.Application.Json) + setBody("{}") + } + val createdList = json.decodeFromString(createResponse.bodyAsText()) + + val uuidAlias = UUID.randomUUID().toString() + val response = client.put("/api/lists/${createdList.id}/alias") { + contentType(ContentType.Application.Json) + setBody("""{"alias": "$uuidAlias"}""") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + val responseBody = response.bodyAsText() + assertTrue(responseBody.contains("UUID format")) + } } \ No newline at end of file diff --git a/frontend/src/components/BurgerMenu.tsx b/frontend/src/components/BurgerMenu.tsx index 5fae3ce..d432b8a 100644 --- a/frontend/src/components/BurgerMenu.tsx +++ b/frontend/src/components/BurgerMenu.tsx @@ -3,13 +3,59 @@ import { createPortal } from 'react-dom'; import ThemeToggle from './ThemeToggle'; import LanguageSwitcher from './LanguageSwitcher'; import { useI18n } from '../context/I18nContext'; +import { apiClient } from '../services/api'; -const BurgerMenu: React.FC = () => { +interface BurgerMenuProps { + listId?: string; + currentAlias?: string | null; + onAliasChanged?: (alias: string | null) => void; +} + +const BurgerMenu: React.FC = ({ listId, currentAlias, onAliasChanged }) => { const [isOpen, setIsOpen] = useState(false); + const [aliasInput, setAliasInput] = useState(currentAlias || ''); + const [aliasSaving, setAliasSaving] = useState(false); + const [aliasError, setAliasError] = useState(null); + const [aliasSuccess, setAliasSuccess] = useState(null); const menuRef = useRef(null); const sheetRef = useRef(null); const { t } = useI18n(); + useEffect(() => { + setAliasInput(currentAlias || ''); + setAliasError(null); + setAliasSuccess(null); + }, [currentAlias, isOpen]); + + const handleAliasSave = async () => { + if (!listId) return; + setAliasSaving(true); + setAliasError(null); + setAliasSuccess(null); + try { + const trimmed = aliasInput.trim().toLowerCase(); + if (trimmed && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(trimmed)) { + setAliasError(t('settings.aliasInvalid')); + setAliasSaving(false); + return; + } + const newAlias = trimmed || null; + const updated = await apiClient.updateAlias(listId, newAlias); + onAliasChanged?.(updated.alias ?? null); + setAliasSuccess(newAlias ? t('settings.aliasSaved') : t('settings.aliasRemoved')); + setTimeout(() => setAliasSuccess(null), 2000); + } catch (error: unknown) { + const apiError = error as { status?: number }; + if (apiError.status === 409) { + setAliasError(t('settings.aliasConflict')); + } else { + setAliasError(t('messages.somethingWentWrong')); + } + } finally { + setAliasSaving(false); + } + }; + // Close on outside click useEffect(() => { if (!isOpen) return; @@ -45,6 +91,47 @@ const BurgerMenu: React.FC = () => { const settingsContent = ( <> + {listId && ( + <> +
+ +

+ {t('settings.aliasDescription')} +

+
+ { + setAliasInput(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')); + setAliasError(null); + setAliasSuccess(null); + }} + placeholder={t('settings.aliasPlaceholder')} + maxLength={64} + className="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" + /> + +
+ {aliasError && ( +

{aliasError}

+ )} + {aliasSuccess && ( +

{aliasSuccess}

+ )} +
+
+ + )} + {/* Theme section */}