From 7ca370700f88e51dda9f3eff4d8c073262b635f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:06:51 -0700 Subject: [PATCH 001/126] New Crowdin translations by GitHub Action (#6660) Co-authored-by: Crowdin Bot --- public/locales/it/common.json | 82 +++---- public/locales/pt_BR/common.json | 46 ++-- public/locales/sr/common.json | 46 ++-- public/locales/tr/common.json | 8 +- public/locales/zh-Hans/common.json | 380 ++++++++++++++--------------- 5 files changed, 281 insertions(+), 281 deletions(-) diff --git a/public/locales/it/common.json b/public/locales/it/common.json index d466d628e..ceec7dafc 100644 --- a/public/locales/it/common.json +++ b/public/locales/it/common.json @@ -247,7 +247,7 @@ "deluge": { "download": "Download", "upload": "Upload", - "leech": "Leech", + "leech": "Leecher", "seed": "Seed" }, "develancacheui": { @@ -350,9 +350,9 @@ "client_version": "Client Version", "os": "OS", "created": "Created", - "authorized": "Authorized", - "is_external": "Is External", - "update_available": "Update Available", + "authorized": "Autorizzato", + "is_external": "È esterno", + "update_available": "Aggiornamento Disponibile", "tags": "Tags", "last_seen": "Ultima visualizzazione", "now": "Adesso", @@ -363,11 +363,11 @@ "minutes": "{{number}}m", "seconds": "{{number}}s", "ago": "{{value}} Fa", - "true": "Yes", + "true": "Si", "false": "No" }, "technitium": { - "totalQueries": "Queries", + "totalQueries": "Richieste", "totalNoError": "Successo", "totalServerFailure": "Fallimenti", "totalNxDomain": "Domini NX", @@ -375,12 +375,12 @@ "totalAuthoritative": "Autoritario", "totalRecursive": "Ricorsivo", "totalCached": "In cache", - "totalBlocked": "Blocked", + "totalBlocked": "Bloccato", "totalDropped": "Saltati", "totalClients": "Client" }, "tdarr": { - "queue": "Queue", + "queue": "Coda", "processed": "Elaborati", "errored": "In errore", "saved": "Salvati" @@ -391,7 +391,7 @@ "middleware": "Middleware" }, "trilium": { - "version": "Version", + "version": "Versione", "notesCount": "Notes", "dbSize": "Database Size", "unknown": "Unknown" @@ -440,8 +440,8 @@ "domain_count": "Domini" }, "medusa": { - "wanted": "Wanted", - "queued": "Queued", + "wanted": "Richiesti/o", + "queued": "In coda", "series": "Serie" }, "minecraft": { @@ -560,9 +560,9 @@ "up_to_date": "Aggiornato", "child_bridges": "Bridge Figli", "child_bridges_status": "{{ok}}/{{total}}", - "up": "Up", - "pending": "Pending", - "down": "Down", + "up": "Su", + "pending": "In attesa", + "down": "Giù", "ok": "Ok" }, "healthchecks": { @@ -571,7 +571,7 @@ "grace": "Periodo di Tolleranza", "down": "Down", "paused": "In Pausa", - "status": "Status", + "status": "Stato", "last_ping": "Ultimo Ping", "never": "Ancora nessun ping" }, @@ -581,27 +581,27 @@ "containers_failed": "Fallito" }, "autobrr": { - "approvedPushes": "Approved", + "approvedPushes": "Approvati", "rejectedPushes": "Rifiutato", "filters": "Filtri", - "indexers": "Indexers" + "indexers": "Indicizzatori" }, "tubearchivist": { - "downloads": "Queue", + "downloads": "Coda", "videos": "Video", "channels": "Canali", "playlists": "Playlist" }, "truenas": { "load": "Carico di Sistema", - "uptime": "Uptime", - "alerts": "Alerts" + "uptime": "Tempo di attività", + "alerts": "Allarmi" }, "pyload": { "speed": "Velocità", - "active": "Active", - "queue": "Queue", - "total": "Total" + "active": "Attivo", + "queue": "Coda", + "total": "Totale" }, "gluetun": { "public_ip": "IP pubblico", @@ -610,34 +610,34 @@ "port_forwarded": "Port Forwarded" }, "hdhomerun": { - "channels": "Channels", + "channels": "Canali", "hd": "HD", "tunerCount": "Regolatori", "channelNumber": "Canale", "channelNetwork": "Rete", "signalStrength": "Intensità", "signalQuality": "Qualità", - "symbolQuality": "Quality", + "symbolQuality": "Qualità", "networkRate": "Bitrate", "clientIP": "Client" }, "scrutiny": { "passed": "Passati", - "failed": "Failed", - "unknown": "Unknown" + "failed": "Fallito", + "unknown": "Sconosciuto" }, "paperlessngx": { "inbox": "In arrivo", - "total": "Total" + "total": "Totale" }, "pangolin": { - "orgs": "Orgs", - "sites": "Sites", - "resources": "Resources", - "targets": "Targets", - "traffic": "Traffic", - "in": "In", - "out": "Out" + "orgs": "Organizzazioni", + "sites": "Siti", + "resources": "Risorse", + "targets": "Obiettivi", + "traffic": "Traffico dati", + "in": "In entrata", + "out": "In Uscita" }, "peanut": { "battery_charge": "Carica Batteria", @@ -648,18 +648,18 @@ "low_battery": "Batteria Quasi Scarica" }, "nextdns": { - "wait": "Please Wait", + "wait": "Attendere prego", "no_devices": "Nessun dato del dispositivo ricevuto" }, "mikrotik": { "cpuLoad": "Carico della CPU", "memoryUsed": "Memoria Utilizzata", - "uptime": "Uptime", + "uptime": "Tempo di funzionamento", "numberOfLeases": "Rilasci" }, "xteve": { "streams_all": "Tutti gli stream", - "streams_active": "Active Streams", + "streams_active": "Trasmissioni attive", "streams_xepg": "Canali XEPG" }, "opendtu": { @@ -669,7 +669,7 @@ "limit": "Limite" }, "opnsense": { - "cpu": "CPU Load", + "cpu": "Carico della CPU", "memory": "Memoria in uso", "wanUpload": "WAN Upload", "wanDownload": "WAN Download" @@ -681,14 +681,14 @@ "layers": "Livelli" }, "octoprint": { - "printer_state": "Status", + "printer_state": "Stato", "temp_tool": "Temp. utensile", "temp_bed": "Temp. letto", "job_completion": "Completamento" }, "cloudflared": { "origin_ip": "IP sorgente", - "status": "Status" + "status": "Stato" }, "pfsense": { "load": "Carico Medio", diff --git a/public/locales/pt_BR/common.json b/public/locales/pt_BR/common.json index 500c2a9a7..8e6fefb14 100644 --- a/public/locales/pt_BR/common.json +++ b/public/locales/pt_BR/common.json @@ -22,7 +22,7 @@ }, "widget": { "missing_type": "Tipo de Widget ausente: {{type}}", - "api_error": "Erros de API", + "api_error": "Erro da API", "information": "Informação", "status": "Status", "url": "Endereço URL", @@ -344,16 +344,16 @@ "address": "Endereço", "expires": "Expira em", "never": "Nunca", - "user": "User", - "hostname": "Hostname", - "name": "Name", - "client_version": "Client Version", - "os": "OS", - "created": "Created", - "authorized": "Authorized", - "is_external": "Is External", - "update_available": "Update Available", - "tags": "Tags", + "user": "Usuário", + "hostname": "Nome do host", + "name": "Nome", + "client_version": "Versão do cliente", + "os": "SO", + "created": "Criado", + "authorized": "Autorizado", + "is_external": "É Externo", + "update_available": "Atualização disponível", + "tags": "Marcadores", "last_seen": "Visto por último", "now": "Agora", "years": "{{number}}a", @@ -363,8 +363,8 @@ "minutes": "{{number}}m", "seconds": "{{number}}s", "ago": "{{value}} Atrás", - "true": "Yes", - "false": "No" + "true": "Sim", + "false": "Não" }, "technitium": { "totalQueries": "Consultas", @@ -937,17 +937,17 @@ "criticals": "Críticos" }, "ntfy": { - "title": "Title", - "priority": "Priority", - "lastReceived": "Last Received", - "message": "Message", - "tags": "Tags", - "none": "None", - "min": "Min", + "title": "Título", + "priority": "Prioridade", + "lastReceived": "Atualização mais recente em", + "message": "Mensagem", + "tags": "Marcadores", + "none": "Nenhum", + "min": "Mín", "low": "Low", - "default": "Default", - "high": "High", - "urgent": "Urgent" + "default": "Padrão", + "high": "Alto", + "urgent": "Urgente" }, "plantit": { "events": "Eventos", diff --git a/public/locales/sr/common.json b/public/locales/sr/common.json index 7589fcbcf..371fb16e4 100644 --- a/public/locales/sr/common.json +++ b/public/locales/sr/common.json @@ -344,16 +344,16 @@ "address": "Адреса", "expires": "Истиче", "never": "Никада", - "user": "User", - "hostname": "Hostname", - "name": "Name", - "client_version": "Client Version", - "os": "OS", - "created": "Created", - "authorized": "Authorized", - "is_external": "Is External", - "update_available": "Update Available", - "tags": "Tags", + "user": "Корисник", + "hostname": "Име хоста", + "name": "Назив", + "client_version": "Верзија клијента", + "os": "ОС", + "created": "Креирано", + "authorized": "Овлашћено", + "is_external": "Је спољни", + "update_available": "Доступно ажурирање", + "tags": "Ознаке", "last_seen": "Последње виђен", "now": "Сада", "years": "{{number}}г", @@ -363,8 +363,8 @@ "minutes": "{{number}}м", "seconds": "{{number}}с", "ago": "Пре {{value}}", - "true": "Yes", - "false": "No" + "true": "Да", + "false": "Не" }, "technitium": { "totalQueries": "Упити", @@ -937,17 +937,17 @@ "criticals": "Критично" }, "ntfy": { - "title": "Title", - "priority": "Priority", - "lastReceived": "Last Received", - "message": "Message", - "tags": "Tags", - "none": "None", - "min": "Min", - "low": "Low", - "default": "Default", - "high": "High", - "urgent": "Urgent" + "title": "Назив", + "priority": "Приоритет", + "lastReceived": "Последње примљено", + "message": "Порука", + "tags": "Ознаке", + "none": "Без", + "min": "Мин", + "low": "Ниско", + "default": "Подразумевано", + "high": "Високо", + "urgent": "Хитно" }, "plantit": { "events": "Догађаји", diff --git a/public/locales/tr/common.json b/public/locales/tr/common.json index 6ffbf3a8d..69af6aabc 100644 --- a/public/locales/tr/common.json +++ b/public/locales/tr/common.json @@ -295,10 +295,10 @@ "available": "Kullanılabilir" }, "seerr": { - "pending": "Pending", - "approved": "Approved", - "available": "Available", - "completed": "Completed", + "pending": "Bekleyen", + "approved": "Onaylanan", + "available": "Kullanılabilir", + "completed": "Tamamlanan", "processing": "Processing", "issues": "Open Issues" }, diff --git a/public/locales/zh-Hans/common.json b/public/locales/zh-Hans/common.json index 9de7ca007..cb487b6e8 100644 --- a/public/locales/zh-Hans/common.json +++ b/public/locales/zh-Hans/common.json @@ -67,9 +67,9 @@ "empty_data": "子系统状态未知" }, "unifi_drive": { - "healthy": "Healthy", - "degraded": "Degraded", - "no_data": "No storage data available" + "healthy": "正常", + "degraded": "已降级", + "no_data": "无可用存储数据" }, "docker": { "rx": "接收", @@ -132,9 +132,9 @@ "evcc": { "pv_power": "正式环境", "battery_soc": "电量", - "grid_power": "Grid", - "home_power": "Consumption", - "charge_power": "Charger", + "grid_power": "电网功率", + "home_power": "全屋功率", + "charge_power": "充电功率", "kilowatt": "kW" }, "flood": { @@ -164,8 +164,8 @@ "received": "已接收", "sent": "已发送", "externalIPAddress": "外部IP", - "externalIPv6Address": "", - "externalIPv6Prefix": "Ext. IPv6-Prefix" + "externalIPv6Address": "外部 IPv6", + "externalIPv6Prefix": "外部 IPv6 前缀" }, "caddy": { "upstreams": "上游", @@ -173,7 +173,7 @@ "requests_failed": "失败请求" }, "changedetectionio": { - "totalObserved": "观察到的总数", + "totalObserved": "总监测数", "diffsDetected": "检测到差异" }, "channelsdvrserver": { @@ -187,13 +187,13 @@ "transcoding": "转码", "bitrate": "比特率", "no_active": "暂无播放", - "plex_connection_error": "Check Plex Connection" + "plex_connection_error": "检查Plex连接" }, "tracearr": { "no_active": "暂无播放", - "streams": "Streams", - "transcodes": "Transcodes", - "directplay": "Direct Play", + "streams": "串流", + "transcodes": "转码", + "directplay": "直接播放", "bitrate": "比特率" }, "omada": { @@ -226,8 +226,8 @@ }, "transmission": { "download": "下载", - "upload": "", - "leech": "Leech", + "upload": "上传", + "leech": "下载中", "seed": "做种" }, "qbittorrent": { @@ -241,8 +241,8 @@ "memUsage": "内存使用", "systemTempC": "系统温度", "poolUsage": "存储池", - "volumeUsage": "Volume Usage", - "invalid": "Invalid" + "volumeUsage": "分卷使用率", + "invalid": "无效的" }, "deluge": { "download": "下载", @@ -255,9 +255,9 @@ "cachemissbytes": "缓存Bytes失败" }, "downloadstation": { - "download": "Download", + "download": "下载", "upload": "上传速率", - "leech": "", + "leech": "下载中", "seed": "做种" }, "sonarr": { @@ -278,7 +278,7 @@ "lidarr": { "wanted": "想看", "queued": "队列中", - "artists": "Artists" + "artists": "艺术家" }, "readarr": { "wanted": "想看", @@ -295,16 +295,16 @@ "available": "可用" }, "seerr": { - "pending": "Pending", - "approved": "Approved", - "available": "Available", - "completed": "Completed", - "processing": "Processing", - "issues": "Open Issues" + "pending": "等待中", + "approved": "已批准", + "available": "可用", + "completed": "已完成", + "processing": "处理中", + "issues": "待处理问题" }, "netalertx": { - "total": "Total", - "connected": "Connected", + "total": "总计", + "connected": "已连接", "new_devices": "新设备", "down_alerts": "离线警报" }, @@ -315,15 +315,15 @@ "gravity": "屏蔽列表" }, "adguard": { - "queries": "Queries", - "blocked": "Blocked", - "filtered": "过滤", + "queries": "查询量", + "blocked": "已拦截", + "filtered": "已过滤", "latency": "延迟" }, "speedtest": { - "upload": "Upload", - "download": "Download", - "ping": "Ping" + "upload": "上传", + "download": "下载", + "ping": "延迟" }, "portainer": { "running": "运行中", @@ -331,10 +331,10 @@ "total": "总计" }, "suwayomi": { - "download": "Downloaded", + "download": "已下载", "nondownload": "未下载", - "read": "Read", - "unread": "Unread", + "read": "已读", + "unread": "未读", "downloadedread": "已下载 & 已读", "downloadedunread": "已下载 & 未读", "nondownloadedread": "未下载 & 已读", @@ -344,27 +344,27 @@ "address": "地址", "expires": "失效", "never": "从不", - "user": "User", - "hostname": "Hostname", - "name": "Name", - "client_version": "Client Version", - "os": "OS", - "created": "Created", - "authorized": "Authorized", - "is_external": "Is External", - "update_available": "Update Available", - "tags": "Tags", + "user": "用户", + "hostname": "主机名", + "name": "名称", + "client_version": "客户端版本", + "os": "操作系统", + "created": "已创建", + "authorized": "已授权", + "is_external": "来自外部", + "update_available": "可更新", + "tags": "标签", "last_seen": "最后上线", "now": "现在", "years": "{{number}}年", "weeks": "{{number}}周", - "days": "{{number}}d", - "hours": "{{number}}h", - "minutes": "{{number}}m", - "seconds": "{{number}}s", + "days": "{{number}}天", + "hours": "{{number}}时", + "minutes": "{{number}}分", + "seconds": "{{number}}秒", "ago": "{{value}} 以前", - "true": "Yes", - "false": "No" + "true": "是", + "false": "否" }, "technitium": { "totalQueries": "Queries", @@ -447,9 +447,9 @@ "minecraft": { "players": "玩家", "version": "版本", - "status": "Status", - "up": "Online", - "down": "Offline" + "status": "状态", + "up": "在线", + "down": "离线" }, "miniflux": { "read": "已读", @@ -471,26 +471,26 @@ "load": "负载", "wait": "请稍候", "temp": "温度", - "_temp": "Temp", - "warn": "Warn", + "_temp": "温度", + "warn": "警告", "uptime": "运行时间", "total": "总计", "free": "空闲", "used": "已使用", "days": "日", "hours": "时", - "crit": "Crit", + "crit": "严重", "read": "读取", "write": "写入", "gpu": "GPU", - "mem": "Mem", + "mem": "内存", "swap": "Swap" }, "quicklaunch": { "bookmark": "书签", "service": "服务", "search": "搜索", - "custom": "自定", + "custom": "自定义", "visit": "访问", "url": "URL", "searchsuggestion": "建议" @@ -554,24 +554,24 @@ "99-night": "雷雨伴随冰雹" }, "homebridge": { - "available_update": "System", + "available_update": "系统", "updates": "更新", "update_available": "有可用的更新", - "up_to_date": "Up to Date", + "up_to_date": "已是最新版本", "child_bridges": "子网桥", "child_bridges_status": "{{ok}}/{{total}}", - "up": "Up", - "pending": "Pending", - "down": "Down", - "ok": "Ok" + "up": "在线", + "pending": "等待中", + "down": "离线", + "ok": "确认" }, "healthchecks": { - "new": "新建立", - "up": "Up", + "new": "新增", + "up": "在线", "grace": "延缓中", - "down": "Down", - "paused": "暂停", - "status": "Status", + "down": "离线", + "paused": "已暂停", + "status": "状态", "last_ping": "上次检查", "never": "尚未检查" }, @@ -583,7 +583,7 @@ "autobrr": { "approvedPushes": "Approved", "rejectedPushes": "拒绝", - "filters": "Filters", + "filters": "过滤器", "indexers": "Indexers" }, "tubearchivist": { @@ -663,13 +663,13 @@ "streams_xepg": "XEPG 频道" }, "opendtu": { - "yieldDay": "Today", - "absolutePower": "Power", - "relativePower": "Power %", - "limit": "Limit" + "yieldDay": "今日", + "absolutePower": "功率", + "relativePower": "功率 %", + "limit": "限制" }, "opnsense": { - "cpu": "CPU Load", + "cpu": "CPU 负载", "memory": "内存", "wanUpload": "WAN上传", "wanDownload": "WAN下载" @@ -715,15 +715,15 @@ "uptimekuma": { "up": "在线网站", "down": "离线网站", - "uptime": "Uptime", + "uptime": "在线率", "incident": "严重事件", - "m": "m" + "m": "分" }, "atsumeru": { "series": "Series", - "archives": "Archives", - "chapters": "Chapters", - "categories": "Categories" + "archives": "存档", + "chapters": "章节", + "categories": "类别" }, "komga": { "libraries": "书库", @@ -731,9 +731,9 @@ "books": "Books" }, "diskstation": { - "days": "Days", - "uptime": "Uptime", - "volumeAvailable": "Available" + "days": "天", + "uptime": "运行时间", + "volumeAvailable": "可用存储" }, "dispatcharr": { "channels": "Channels", @@ -745,15 +745,15 @@ "wanted": "Wanted" }, "photoprism": { - "albums": "Albums", - "photos": "Photos", - "videos": "Videos", + "albums": "专辑", + "photos": "照片", + "videos": "视频", "people": "人物" }, "fileflows": { - "queue": "Queue", - "processing": "Processing", - "processed": "Processed", + "queue": "队列", + "processing": "处理中", + "processed": "已处理", "time": "时间" }, "firefly": { @@ -775,11 +775,11 @@ "numshares": "共享项目" }, "kopia": { - "status": "Status", + "status": "状态", "size": "大小", "lastrun": "最后运行", "nextrun": "下次运行", - "failed": "Failed" + "failed": "失败" }, "unmanic": { "active_workers": "在线工作节点", @@ -796,9 +796,9 @@ "targets_total": "总目标" }, "gatus": { - "up": "Sites Up", - "down": "Sites Down", - "uptime": "Uptime" + "up": "在线网站", + "down": "离线网站", + "uptime": "运行时间" }, "ghostfolio": { "gross_percent_today": "Today", @@ -828,10 +828,10 @@ "series": "系列" }, "booklore": { - "libraries": "Libraries", - "books": "Books", - "reading": "Reading", - "finished": "Finished" + "libraries": "书库", + "books": "书籍", + "reading": "阅读中", + "finished": "已读完" }, "jdownloader": { "downloadCount": "队列", @@ -844,44 +844,44 @@ "totalFiles": "文件" }, "azuredevops": { - "result": "Result", + "result": "结果", "status": "Status", - "buildId": "Build ID", - "succeeded": "Succeeded", - "notStarted": "Not Started", + "buildId": "构建 ID", + "succeeded": "成功", + "notStarted": "尚未开始", "failed": "失败", - "canceled": "Canceled", - "inProgress": "In Progress", - "totalPrs": "Total PRs", - "myPrs": "My PRs", + "canceled": "已取消", + "inProgress": "处理中", + "totalPrs": "总 PR", + "myPrs": "我的 PR", "approved": "Approved" }, "gamedig": { - "status": "Status", - "online": "Online", - "offline": "Offline", - "name": "Name", - "map": "Map", - "currentPlayers": "Current players", - "players": "Players", - "maxPlayers": "Max players", - "bots": "Bots", - "ping": "Ping" + "status": "状态", + "online": "在线", + "offline": "离线", + "name": "名称", + "map": "地图", + "currentPlayers": "当前玩家", + "players": "玩家", + "maxPlayers": "玩家上限", + "bots": "机器人", + "ping": "延迟" }, "urbackup": { - "ok": "Ok", - "errored": "Errors", - "noRecent": "Out of Date", - "totalUsed": "Used Storage" + "ok": "成功", + "errored": "错误", + "noRecent": "已过期", + "totalUsed": "使用的存储" }, "mealie": { - "recipes": "Recipes", + "recipes": "食谱", "users": "Users", "categories": "Categories", - "tags": "Tags" + "tags": "标签" }, "openmediavault": { - "downloading": "Downloading", + "downloading": "下载中", "total": "Total", "running": "Running", "stopped": "Stopped", @@ -889,34 +889,34 @@ "failed": "Failed" }, "openwrt": { - "uptime": "Uptime", + "uptime": "运行时间", "cpuLoad": "CPU 负载平均值(5m)", - "up": "Up", - "down": "Down", + "up": "在线", + "down": "离线", "bytesTx": "已传输", - "bytesRx": "Received" + "bytesRx": "已接收" }, "uptimerobot": { - "status": "Status", - "uptime": "Uptime", - "lastDown": "Last Downtime", - "downDuration": "Downtime Duration", - "sitesUp": "Sites Up", - "sitesDown": "Sites Down", - "paused": "Paused", - "notyetchecked": "Not Yet Checked", - "up": "Up", - "seemsdown": "Seems Down", - "down": "Down", - "unknown": "Unknown" + "status": "状态", + "uptime": "运行时间", + "lastDown": "上次离线时间", + "downDuration": "离线时长", + "sitesUp": "在线网站", + "sitesDown": "离线网站", + "paused": "已暂停", + "notyetchecked": "尚未检查", + "up": "在线", + "seemsdown": "似乎离线", + "down": "离线", + "unknown": "未知" }, "calendar": { - "inCinemas": "In cinemas", - "physicalRelease": "Physical release", - "digitalRelease": "Digital release", + "inCinemas": "上映中", + "physicalRelease": "实体发行", + "digitalRelease": "数字发行", "noEventsToday": "今天没有活动!", "noEventsFound": "未找到事件", - "errorWhenLoadingData": "Error when loading calendar data" + "errorWhenLoadingData": "加载日历数据时出错" }, "romm": { "platforms": "平台", @@ -1007,15 +1007,15 @@ "banned": "已禁止" }, "myspeed": { - "ping": "Ping", - "download": "Download", - "upload": "Upload" + "ping": "延迟", + "download": "下载", + "upload": "上传" }, "stocks": { "stocks": "库存", "loading": "正在加载", - "open": "打開-美国商店", - "closed": "关闭-美国市场", + "open": "开放 - 美国市场", + "closed": "关闭 - 美国市场", "invalidConfiguration": "无效配置" }, "frigate": { @@ -1045,18 +1045,18 @@ "none": "空" }, "vikunja": { - "projects": "积极的项目", + "projects": "活跃项目", "tasks7d": "本周到期的任务", "tasksOverdue": "过期的任务", "tasksInProgress": "正在处理的任务" }, "headscale": { - "name": "Name", - "address": "Address", - "last_seen": "Last Seen", - "status": "Status", - "online": "Online", - "offline": "Offline" + "name": "名称", + "address": "地址", + "last_seen": "最后上线", + "status": "状态", + "online": "在线", + "offline": "离线" }, "beszel": { "name": "Name", @@ -1080,7 +1080,7 @@ "degraded": "已降级", "progressing": "进行中", "missing": "Missing", - "suspended": "已停用" + "suspended": "已暂停" }, "spoolman": { "loading": "Loading" @@ -1106,15 +1106,15 @@ "tags": "Tags" }, "slskd": { - "slskStatus": "Network", - "connected": "Connected", - "disconnected": "Disconnected", + "slskStatus": "网络", + "connected": "已连接", + "disconnected": "已断开", "updateStatus": "更新", - "update_yes": "Available", - "update_no": "Up to Date", + "update_yes": "可用", + "update_no": "已是最新", "downloads": "下载", "uploads": "上传", - "sharedFiles": "Files" + "sharedFiles": "文件" }, "jellystat": { "songs": "歌曲", @@ -1127,15 +1127,15 @@ "hostErrors": "Host issues" }, "komodo": { - "total": "Total", - "running": "Running", - "stopped": "Stopped", - "down": "Down", - "unhealthy": "Unhealthy", - "unknown": "Unknown", - "servers": "Servers", - "stacks": "Stacks", - "containers": "Containers" + "total": "总计", + "running": "运行中", + "stopped": "已停止", + "down": "离线", + "unhealthy": "异常", + "unknown": "未知", + "servers": "服务器", + "stacks": "堆栈", + "containers": "容器" }, "filebrowser": { "available": "Available", @@ -1150,26 +1150,26 @@ "nextRenewingSubscription": "Next Payment" }, "unraid": { - "STARTED": "Started", + "STARTED": "已启动", "STOPPED": "已停止", - "NEW_ARRAY": "New Array", - "RECON_DISK": "Reconstructing Disk", - "DISABLE_DISK": "Disk Disabled", - "SWAP_DSBL": "Swap Disable", - "INVALID_EXPANSION": "Invalid Expansion", - "PARITY_NOT_BIGGEST": "Parity Not Biggest", - "TOO_MANY_MISSING_DISKS": "Too Many Missing Disks", - "NEW_DISK_TOO_SMALL": "New Disk Too Small", - "NO_DATA_DISKS": "No Data Disks", - "notifications": "Notifications", - "status": "Status", + "NEW_ARRAY": "新阵列", + "RECON_DISK": "正在重建磁盘", + "DISABLE_DISK": "磁盘已禁用", + "SWAP_DSBL": "禁用 Swap", + "INVALID_EXPANSION": "无效扩展", + "PARITY_NOT_BIGGEST": "非最大分区", + "TOO_MANY_MISSING_DISKS": "丢失磁盘过多", + "NEW_DISK_TOO_SMALL": "新磁盘过小", + "NO_DATA_DISKS": "无数据磁盘", + "notifications": "通知", + "status": "状态", "cpu": "CPU", "memoryUsed": "已用内存", "memoryAvailable": "可用内存", - "arrayUsed": "Array Used", - "arrayFree": "Array Free", - "poolUsed": "{{pool}} Used", - "poolFree": "{{pool}} Free" + "arrayUsed": "已用阵列", + "arrayFree": "空闲阵列", + "poolUsed": "已用 {{pool}}", + "poolFree": "空闲 {{pool}}" }, "backrest": { "num_plans": "Plans", From 48bdb7f0b02468def52534b4d96b7d6a6d492597 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:29:02 -0700 Subject: [PATCH 002/126] Chore(deps): Bump actions/checkout from 6.0.2 to 6.0.3 (#6764) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/crowdin.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/docs-publish.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 136e70bc3..2c29170cb 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: crowdin action uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2 with: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 44599cfab..7bd81fd73 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Extract Docker metadata id: meta diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml index 47ae6cbe4..87ecfc5fd 100644 --- a/.github/workflows/docs-publish.yml +++ b/.github/workflows/docs-publish.yml @@ -20,7 +20,7 @@ jobs: if: github.repository == 'gethomepage/homepage' && github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version-file: ".python-version" @@ -38,7 +38,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version-file: ".python-version" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5a5e1e25a..e3e599f3e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Install python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82127af54..aacd8f6a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: matrix: shard: [1, 2, 3, 4] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 with: From 55f132c7dc0a9d808a039387b5a0bab52c4aa13a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:34:06 +0000 Subject: [PATCH 003/126] Chore(deps): Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 (#6765) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml index 87ecfc5fd..7f813fc81 100644 --- a/.github/workflows/docs-publish.yml +++ b/.github/workflows/docs-publish.yml @@ -25,7 +25,7 @@ jobs: with: python-version-file: ".python-version" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - run: sudo apt-get install pngquant - name: Test Docs Build run: uv run --frozen zensical build --clean @@ -43,7 +43,7 @@ jobs: with: python-version-file: ".python-version" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - run: sudo apt-get install pngquant - name: Build Docs run: uv run --frozen zensical build --clean From f670f465269ac28e98b0100f0e707cc48191fa4a Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:15:58 -0700 Subject: [PATCH 004/126] Feature: homepage auth (#6769) --- README.md | 7 +- docs/installation/index.md | 23 ++ package.json | 1 + pnpm-lock.yaml | 115 ++++++++++ .../pages/api/auth/[...nextauth].test.js | 124 +++++++++++ src/__tests__/pages/auth/signin.test.jsx | 78 +++++++ src/middleware.js | 22 +- src/middleware.test.js | 68 +++++- src/pages/_app.jsx | 47 ++-- src/pages/api/auth/[...nextauth].js | 114 ++++++++++ src/pages/auth/signin.jsx | 210 ++++++++++++++++++ vitest.setup.js | 6 + 12 files changed, 778 insertions(+), 37 deletions(-) create mode 100644 src/__tests__/pages/api/auth/[...nextauth].test.js create mode 100644 src/__tests__/pages/auth/signin.test.jsx create mode 100644 src/pages/api/auth/[...nextauth].js create mode 100644 src/pages/auth/signin.jsx diff --git a/README.md b/README.md index 14931ca7c..88d20b0ec 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,10 @@ For configuration options, examples and more, [please check out the homepage doc ## Security Notice 🔒 -Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system) and Homepage currently does not (and is not planned to) include any authentication layer itself. If Homepage is reachable from any untrusted network, it **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. The built-in host check in Homepage is a best-effort guard and should not be treated as security when exposed publicly. +Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system). To keep your information private, if Homepage is reachable from any untrusted network, it: + +1. **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. +2. An optional built-in OIDC login flow is available (opt-in) offering a simple “authenticated or not” guard. ## With Docker @@ -120,7 +123,7 @@ If this is your first time starting, copy the `src/skeleton` directory to `confi Finally, run the server in production mode: ```bash -pnpm start +HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start ``` # Configuration diff --git a/docs/installation/index.md b/docs/installation/index.md index b6bfb23b5..d31f540c4 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -38,3 +38,26 @@ The value is a comma-separated (no spaces) list of allowed hosts (sometimes with If you are seeing errors about host validation, check the homepage logs and ensure that the host exactly as output in the logs is in the `HOMEPAGE_ALLOWED_HOSTS` list. This can be disabled by setting `HOMEPAGE_ALLOWED_HOSTS` to `*` but this is not recommended. Public deployments must rely on a reverse proxy (and/or VPN) that enforces authentication, TLS, and unexpected Host headers; the built-in host check is a best-effort guard for local setups and is not a substitute for edge protections. + +### Security & Authentication + +Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple authorization gate with a password or OIDC. When enabled, Homepage will use password login by default unless OIDC variables are provided. + +Required environment variables for authentication: + +- `HOMEPAGE_AUTH_ENABLED=true` +- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies) + +For password-only login: + +- `HOMEPAGE_AUTH_PASSWORD` (password-only login; required unless OIDC settings are provided) + +For OIDC login (overrides password login): + +- `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`) +- `HOMEPAGE_OIDC_CLIENT_ID` +- `HOMEPAGE_OIDC_CLIENT_SECRET` +- `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks) +- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`) + +All app pages and `/api` routes will require a signed-in session. Static assets remain public. Homepage still does not implement per-user dashboards or roles; authentication is a simple gate only. diff --git a/package.json b/package.json index 1ceeeea3b..84138fdde 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "memory-cache": "^0.2.0", "minecraftstatuspinger": "^1.2.2", "next": "^16.2.6", + "next-auth": "^4.24.10", "next-i18next": "^16.0.7", "ping": "^0.4.4", "pretty-bytes": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbbfccf72..0e6b62d9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: next: specifier: ^16.2.6 version: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next-auth: + specifier: ^4.24.10 + version: 4.24.14(next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-i18next: specifier: ^16.0.7 version: 16.0.7(@types/react@19.0.10)(i18next@26.3.0(typescript@5.7.3))(next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-i18next@15.5.3(i18next@26.3.0(typescript@5.7.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.7.3))(react@19.2.5) @@ -842,6 +845,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1708,6 +1714,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -2556,6 +2566,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} @@ -2723,6 +2736,10 @@ packages: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -2834,6 +2851,20 @@ packages: net@1.0.2: resolution: {integrity: sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==} + next-auth@4.24.14: + resolution: {integrity: sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==} + peerDependencies: + '@auth/core': 0.34.3 + next: ^12.2.5 || ^13 || ^14 || ^15 || ^16 + nodemailer: ^7.0.7 + react: ^17.0.2 || ^18 || ^19 + react-dom: ^17.0.2 || ^18 || ^19 + peerDependenciesMeta: + '@auth/core': + optional: true + nodemailer: + optional: true + next-i18next@16.0.7: resolution: {integrity: sha512-IyH9aDsDFqifndvHYQyJMJq0syLBm/E7MNrdCkAFl/5wlQ4bk4JUeLIIJC1wybWjEhYQLwWB6/dPeEf7LmqLTg==} engines: {node: '>=20'} @@ -2880,10 +2911,17 @@ packages: oauth4webapi@3.3.0: resolution: {integrity: sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==} + oauth@0.9.15: + resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2915,12 +2953,19 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + openid-client@6.3.0: resolution: {integrity: sha512-68LMqb/Whtq214B9c9kCtuniCKQrEqWJRTEoOEZlv2QV5VgqhjySCpBe4RXeU+pj/VNOi7erP/ixxfHtqR7FOw==} @@ -3005,6 +3050,14 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@5.2.6: + resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} + peerDependencies: + preact: '>=10' + + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3036,6 +3089,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + prism-react-renderer@2.4.1: resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} peerDependencies: @@ -3621,6 +3677,11 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} @@ -3816,6 +3877,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -4367,6 +4431,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@panva/hkdf@1.2.1': {} + '@pkgr/core@0.2.9': {} '@protobufjs/aspromise@1.1.2': {} @@ -5203,6 +5269,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@0.7.2: {} + core-util-is@1.0.3: {} cpu-features@0.0.10: @@ -6276,6 +6344,8 @@ snapshots: jiti@2.6.1: {} + jose@4.15.9: {} + jose@5.10.0: {} js-tokens@10.0.0: {} @@ -6433,6 +6503,10 @@ snapshots: lru-cache@11.2.6: {} + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + luxon@3.7.2: {} lz-string@1.5.0: {} @@ -6513,6 +6587,21 @@ snapshots: net@1.0.2: {} + next-auth@4.24.14(next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@babel/runtime': 7.29.7 + '@panva/hkdf': 1.2.1 + cookie: 0.7.2 + jose: 4.15.9 + next: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + oauth: 0.9.15 + openid-client: 5.7.1 + preact: 10.29.2 + preact-render-to-string: 5.2.6(preact@10.29.2) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + uuid: 8.3.2 + next-i18next@16.0.7(@types/react@19.0.10)(i18next@26.3.0(typescript@5.7.3))(next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-i18next@15.5.3(i18next@26.3.0(typescript@5.7.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.7.3))(react@19.2.5): dependencies: '@types/hoist-non-react-statics': 3.3.7(@types/react@19.0.10) @@ -6557,8 +6646,12 @@ snapshots: oauth4webapi@3.3.0: {} + oauth@0.9.15: {} + object-assign@4.1.1: {} + object-hash@2.2.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -6600,6 +6693,8 @@ snapshots: obug@2.1.1: {} + oidc-token-hash@5.2.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -6608,6 +6703,13 @@ snapshots: dependencies: fn.name: 1.1.0 + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + openid-client@6.3.0: dependencies: jose: 5.10.0 @@ -6687,6 +6789,13 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@5.2.6(preact@10.29.2): + dependencies: + preact: 10.29.2 + pretty-format: 3.8.0 + + preact@10.29.2: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -6708,6 +6817,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-format@3.8.0: {} + prism-react-renderer@2.4.1(react@19.2.5): dependencies: '@types/prismjs': 1.26.5 @@ -7429,6 +7540,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + varint@6.0.0: {} victory-vendor@37.3.6: @@ -7625,6 +7738,8 @@ snapshots: y18n@5.0.8: {} + yallist@4.0.0: {} + yallist@5.0.0: {} yargs-parser@21.1.1: {} diff --git a/src/__tests__/pages/api/auth/[...nextauth].test.js b/src/__tests__/pages/api/auth/[...nextauth].test.js new file mode 100644 index 000000000..2a510d514 --- /dev/null +++ b/src/__tests__/pages/api/auth/[...nextauth].test.js @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { nextAuthMock } = vi.hoisted(() => ({ + nextAuthMock: vi.fn((options) => ({ options })), +})); + +vi.mock("next-auth", () => ({ + default: nextAuthMock, +})); + +describe("pages/api/auth/[...nextauth]", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + nextAuthMock.mockClear(); + process.env = { ...originalEnv }; + delete process.env.NEXTAUTH_SECRET; + delete process.env.NEXTAUTH_URL; + }); + + it("configures no providers when auth is disabled", async () => { + const mod = await import("pages/api/auth/[...nextauth]"); + + expect(nextAuthMock).toHaveBeenCalledTimes(1); + expect(mod.default.options.providers).toEqual([]); + expect(mod.default.options.pages?.signIn).toBe("/auth/signin"); + }); + + it("maps HOMEPAGE_AUTH_SECRET and HOMEPAGE_EXTERNAL_URL to NextAuth envs", async () => { + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; + + const mod = await import("pages/api/auth/[...nextauth]"); + + expect(process.env.NEXTAUTH_SECRET).toBe("secret"); + expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example"); + expect(mod.default.options.secret).toBe("secret"); + }); + + it("throws when auth is enabled but no provider settings are present", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + + await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( + /Password auth is enabled but required settings are missing/i, + ); + }); + + it("builds a password provider when auth is enabled without OIDC config", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; + process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + + const mod = await import("pages/api/auth/[...nextauth]"); + const [provider] = mod.default.options.providers; + + expect(provider.id).toBe("credentials"); + expect(provider.name).toBe("Credentials"); + expect(provider.type).toBe("credentials"); + expect(typeof provider.authorize).toBe("function"); + }); + + it("builds an OIDC provider when enabled and maps profile fields", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/"; + process.env.HOMEPAGE_OIDC_CLIENT_ID = "client-id"; + process.env.HOMEPAGE_OIDC_CLIENT_SECRET = "client-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; + process.env.HOMEPAGE_OIDC_NAME = "My OIDC"; + process.env.HOMEPAGE_OIDC_SCOPE = "openid email"; + + const mod = await import("pages/api/auth/[...nextauth]"); + const [provider] = mod.default.options.providers; + + expect(provider).toMatchObject({ + id: "homepage-oidc", + name: "My OIDC", + type: "oauth", + idToken: true, + issuer: "https://issuer.example", + wellKnown: "https://issuer.example/.well-known/openid-configuration", + clientId: "client-id", + clientSecret: "client-secret", + }); + expect(provider.authorization.params.scope).toBe("openid email"); + + expect( + provider.profile({ + sub: "sub", + preferred_username: "user", + email: "user@example.com", + picture: "https://example.com/p.png", + }), + ).toEqual({ + id: "sub", + name: "user", + email: "user@example.com", + image: "https://example.com/p.png", + }); + + expect( + provider.profile({ + id: "id", + name: "name", + }), + ).toEqual({ + id: "id", + name: "name", + email: null, + image: null, + }); + }); + + it("throws when only partial OIDC settings are provided", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example"; + process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + + await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( + /OIDC auth is enabled but required settings are missing/i, + ); + }); +}); diff --git a/src/__tests__/pages/auth/signin.test.jsx b/src/__tests__/pages/auth/signin.test.jsx new file mode 100644 index 000000000..9b40b147f --- /dev/null +++ b/src/__tests__/pages/auth/signin.test.jsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const { getSettingsMock } = vi.hoisted(() => ({ + getSettingsMock: vi.fn(), +})); + +vi.mock("utils/config/config", () => ({ + getSettings: getSettingsMock, +})); + +vi.mock("next/router", () => ({ + useRouter: () => ({ + query: {}, + }), +})); + +import { getProviders } from "next-auth/react"; +import SignInPage, { getServerSideProps } from "pages/auth/signin"; + +describe("pages/auth/signin", () => { + it("renders an error state when no providers are configured", async () => { + render( + , + ); + + expect(screen.getByText("Authentication not configured")).toBeInTheDocument(); + + await waitFor(() => { + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(document.documentElement.classList.contains("scheme-dark")).toBe(true); + expect(document.documentElement.classList.contains("theme-slate")).toBe(true); + }); + }); + + it("renders provider buttons when providers are available", () => { + render( + , + ); + + expect(screen.getByText("Sign in")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument(); + }); + + it("getServerSideProps returns providers and settings", async () => { + getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } }); + getSettingsMock.mockReturnValueOnce({ theme: "dark" }); + + const res = await getServerSideProps({}); + + expect(getProviders).toHaveBeenCalled(); + expect(getSettingsMock).toHaveBeenCalled(); + expect(res).toEqual({ + props: { + providers: { foo: { id: "foo", name: "Foo" } }, + settings: { theme: "dark" }, + }, + }); + }); +}); diff --git a/src/middleware.js b/src/middleware.js index 39452f6cc..7d4052692 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -1,6 +1,10 @@ +import { getToken } from "next-auth/jwt"; import { NextResponse } from "next/server"; -export function middleware(req) { +const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED); +const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET; + +export async function middleware(req) { // Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set const host = req.headers.get("host"); const port = process.env.PORT || 3000; @@ -15,9 +19,23 @@ export function middleware(req) { ); return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 }); } + + if (authEnabled) { + const token = await getToken({ req, secret: authSecret }); + if (!token) { + const signInUrl = new URL("/auth/signin", req.url); + signInUrl.searchParams.set("callbackUrl", "/"); + return NextResponse.redirect(signInUrl); + } + } + return NextResponse.next(); } export const config = { - matcher: "/api/:path*", + // Protect all app and API routes; allow Next.js internals, public assets, auth pages, and NextAuth endpoints. + matcher: [ + "/", + "/((?!_next/static|_next/image|favicon.ico|robots.txt|manifest.json|sitemap.xml|icons/|api/auth|auth/).*)", + ], }; diff --git a/src/middleware.test.js b/src/middleware.test.js index cb37749ec..5f24c0c23 100644 --- a/src/middleware.test.js +++ b/src/middleware.test.js @@ -1,18 +1,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { NextResponse } = vi.hoisted(() => ({ +const { NextResponse, getToken } = vi.hoisted(() => ({ NextResponse: { json: vi.fn((body, init) => ({ type: "json", body, init })), next: vi.fn(() => ({ type: "next" })), + redirect: vi.fn((url) => ({ type: "redirect", url })), }, + getToken: vi.fn(), })); vi.mock("next/server", () => ({ NextResponse })); +vi.mock("next-auth/jwt", () => ({ getToken })); -import { middleware } from "./middleware"; +async function loadMiddleware() { + vi.resetModules(); + const mod = await import("./middleware"); + return mod.middleware; +} -function createReq(host) { +function createReq(host = "localhost:3000", url = "http://localhost:3000/") { return { + url, headers: { get: (key) => (key === "host" ? host : null), }, @@ -29,42 +37,80 @@ describe("middleware", () => { console.error = originalConsoleError; }); - it("allows requests for default localhost hosts", () => { + it("allows requests for default localhost hosts when auth is disabled", async () => { process.env.PORT = "3000"; - const res = middleware(createReq("localhost:3000")); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000")); expect(NextResponse.next).toHaveBeenCalled(); expect(res).toEqual({ type: "next" }); }); - it("blocks requests when host is not allowed", () => { + it("blocks requests when host is not allowed", async () => { process.env.PORT = "3000"; const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const res = middleware(createReq("evil.com")); + const middleware = await loadMiddleware(); + const res = await middleware(createReq("evil.com")); expect(errSpy).toHaveBeenCalled(); expect(NextResponse.json).toHaveBeenCalledWith( { error: "Host validation failed. See logs for more details." }, { status: 400 }, ); + expect(getToken).not.toHaveBeenCalled(); expect(res.type).toBe("json"); expect(res.init.status).toBe(400); }); - it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", () => { + it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", async () => { process.env.HOMEPAGE_ALLOWED_HOSTS = "*"; - const res = middleware(createReq("anything.example")); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("anything.example")); expect(NextResponse.next).toHaveBeenCalled(); expect(res).toEqual({ type: "next" }); }); - it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", () => { + it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", async () => { process.env.PORT = "3000"; process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com:3000,other:3000"; - const res = middleware(createReq("example.com:3000")); + const middleware = await loadMiddleware(); + const res = await middleware(createReq("example.com:3000", "http://example.com:3000/")); + + expect(NextResponse.next).toHaveBeenCalled(); + expect(res).toEqual({ type: "next" }); + }); + + it("redirects to signin when auth is enabled and no token is present", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + + getToken.mockResolvedValueOnce(null); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/some")); + + expect(getToken).toHaveBeenCalledWith({ + req: expect.objectContaining({ url: "http://localhost:3000/some" }), + secret: "secret", + }); + expect(NextResponse.redirect).toHaveBeenCalled(); + expect(res.type).toBe("redirect"); + expect(String(res.url)).toContain("/auth/signin"); + }); + + it("allows requests when auth is enabled and a token is present", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + + getToken.mockResolvedValueOnce({ sub: "user" }); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/")); expect(NextResponse.next).toHaveBeenCalled(); expect(res).toEqual({ type: "next" }); diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index ef90b4bad..3767d366d 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -1,4 +1,5 @@ /* eslint-disable react/jsx-props-no-spreading */ +import { SessionProvider } from "next-auth/react"; import { appWithTranslation } from "next-i18next/pages"; import Head from "next/head"; import "styles/globals.css"; @@ -72,28 +73,30 @@ const tailwindSafelist = [ function MyApp({ Component, pageProps }) { return ( - fetch(resource, init).then((res) => res.json()), - }} - > - - {/* https://nextjs.org/docs/messages/no-document-viewport-meta */} - - - - - - - - - - - - + + fetch(resource, init).then((res) => res.json()), + }} + > + + {/* https://nextjs.org/docs/messages/no-document-viewport-meta */} + + + + + + + + + + + + + ); } diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js new file mode 100644 index 000000000..a6c3f652c --- /dev/null +++ b/src/pages/api/auth/[...nextauth].js @@ -0,0 +1,114 @@ +import { timingSafeEqual } from "node:crypto"; + +import NextAuth from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; + +const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED); +const issuer = process.env.HOMEPAGE_OIDC_ISSUER; +const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID; +const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET; +const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET; +const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL; +const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD; + +// Map HOMEPAGE_* envs to what NextAuth expects +if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) { + process.env.NEXTAUTH_SECRET = homepageAuthSecret; +} +if (!process.env.NEXTAUTH_URL && homepageExternalUrl) { + process.env.NEXTAUTH_URL = homepageExternalUrl; +} + +const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile"; +const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer; +const hasOidcConfig = Boolean(issuer && clientId && clientSecret); +const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret); + +if (authEnabled) { + if (hasOidcConfig) { + if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) { + throw new Error("OIDC auth is enabled but required settings are missing."); + } + } else if (hasAnyOidcConfig) { + throw new Error("OIDC auth is enabled but required settings are missing."); + } else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) { + throw new Error("Password auth is enabled but required settings are missing."); + } +} + +let providers = []; +if (authEnabled) { + if (hasOidcConfig) { + providers = [ + { + id: "homepage-oidc", + name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC", + type: "oauth", + idToken: true, + issuer: cleanedIssuer, + wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`, + clientId, + clientSecret, + authorization: { + params: { + scope: defaultScope, + }, + }, + profile(profile) { + return { + id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email, + name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email, + email: profile.email ?? null, + image: profile.picture ?? null, + }; + }, + }, + ]; + } else { + providers = [ + CredentialsProvider({ + name: "Password", + credentials: { + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const provided = credentials?.password ?? ""; + const expected = homepageAuthPassword ?? ""; + if (!expected || provided.length !== expected.length) { + return null; + } + const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!isMatch) { + return null; + } + return { + id: "homepage", + name: "Homepage", + }; + }, + }), + ]; + } +} + +export default NextAuth({ + providers, + session: { + strategy: "jwt", + }, + secret: process.env.NEXTAUTH_SECRET, + pages: { + signIn: "/auth/signin", + }, + debug: true, + logger: { + error: (...args) => console.error("[nextauth][error]", ...args), + warn: (...args) => console.warn("[nextauth][warn]", ...args), + debug: (...args) => console.debug("[nextauth][debug]", ...args), + }, + events: { + signIn: async (message) => console.debug("[nextauth][event][signIn]", message), + signOut: async (message) => console.debug("[nextauth][event][signOut]", message), + error: async (message) => console.error("[nextauth][event][error]", message), + }, +}); diff --git a/src/pages/auth/signin.jsx b/src/pages/auth/signin.jsx new file mode 100644 index 000000000..2ff4b8220 --- /dev/null +++ b/src/pages/auth/signin.jsx @@ -0,0 +1,210 @@ +import classNames from "classnames"; +import { getProviders, signIn } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; +import { BiShieldQuarter } from "react-icons/bi"; + +import { getSettings } from "utils/config/config"; + +export default function SignIn({ providers, settings }) { + const router = useRouter(); + const [password, setPassword] = useState(""); + const theme = settings?.theme || "dark"; + const color = settings?.color || "slate"; + const title = settings?.title || "Homepage"; + const callbackUrl = useMemo(() => { + const value = router.query?.callbackUrl; + return typeof value === "string" ? value : "/"; + }, [router.query?.callbackUrl]); + const error = router.query?.error; + + let backgroundImage = ""; + let opacity = settings?.backgroundOpacity ?? 0; + let backgroundBlur = false; + let backgroundSaturate = false; + let backgroundBrightness = false; + + if (settings?.background) { + const bg = settings.background; + if (typeof bg === "object") { + backgroundImage = bg.image || ""; + if (bg.opacity !== undefined) { + opacity = 1 - bg.opacity / 100; + } + backgroundBlur = bg.blur !== undefined; + backgroundSaturate = bg.saturate !== undefined; + backgroundBrightness = bg.brightness !== undefined; + } else { + backgroundImage = bg; + } + } + + useEffect(() => { + const html = document.documentElement; + const body = document.body; + + html.classList.remove("dark", "scheme-dark", "scheme-light"); + html.classList.toggle("dark", theme === "dark"); + html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light"); + + const desiredThemeClass = `theme-${color}`; + const themeClassesToRemove = Array.from(html.classList).filter( + (cls) => cls.startsWith("theme-") && cls !== desiredThemeClass, + ); + if (themeClassesToRemove.length) { + html.classList.remove(...themeClassesToRemove); + } + if (!html.classList.contains(desiredThemeClass)) { + html.classList.add(desiredThemeClass); + } + + body.style.backgroundImage = ""; + body.style.backgroundColor = ""; + body.style.backgroundAttachment = ""; + }, [color, theme]); + + if (!providers || Object.keys(providers).length === 0) { + return ( + <> + {backgroundImage && ( +