From 1a529fc23ea851af0391031201cc1e6983d662fb Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 22 Apr 2026 18:50:56 +0000 Subject: [PATCH] feat: add PIN lock screen auth + calendar owner filter toggles --- .gitignore | 1 + app/api/auth/route.ts | 37 ++++ app/page.tsx | 366 ++++++++++++++++++++++--------------- components/CalendarTab.tsx | 328 ++++++++++++++------------------- middleware.ts | 40 ++++ 5 files changed, 440 insertions(+), 332 deletions(-) create mode 100644 app/api/auth/route.ts create mode 100644 middleware.ts diff --git a/.gitignore b/.gitignore index 72e1136..5ec3ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.tablet.env diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts new file mode 100644 index 0000000..a5ccb20 --- /dev/null +++ b/app/api/auth/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import * as crypto from 'crypto' + +const SECRET = process.env.APP_SECRET || 'smart-home-default-secret-change-me' + +function makeToken(pin: string): string { + return crypto.createHmac('sha256', SECRET).update(pin).digest('hex') +} + +export async function POST(req: Request) { + const { pin } = await req.json() + const correctPin = process.env.APP_PIN || '1234' + + if (pin !== correctPin) { + return NextResponse.json({ error: 'wrong_pin' }, { status: 401 }) + } + + const token = makeToken(correctPin) + const res = NextResponse.json({ success: true }) + + res.cookies.set('auth_token', token, { + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + maxAge: 60 * 60 * 24 * 365, // 1 year — tablet stays logged in + }) + + return res +} + +export async function DELETE() { + const res = NextResponse.json({ success: true }) + res.cookies.delete('auth_token') + return res +} diff --git a/app/page.tsx b/app/page.tsx index 547a321..cce7c4e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,8 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' -import { Thermometer, Droplets, Wind, Calendar, Sun, CloudRain, Snowflake as SnowIcon, Cloud, CloudSun, Zap, Settings as SettingsIcon } from 'lucide-react' +import { useState, useEffect, useCallback, Suspense } from 'react' +import { useSearchParams } from 'next/navigation' +import { Thermometer, Droplets, Wind, Calendar, Lock, Settings as SettingsIcon, LogOut, Delete } from 'lucide-react' import Sidebar from '@/components/Sidebar' import TopBar from '@/components/TopBar' import RoomTabs from '@/components/RoomTabs' @@ -92,6 +93,161 @@ function getPm25Level(pm25: number): { label: string; color: string; bg: string return { label: 'Плохо', color: '#f87171', bg: 'rgba(248,113,113,0.12)' } } +// ————— Lock Screen ————— +function LockScreen({ onUnlock }: { onUnlock: () => void }) { + const [pin, setPin] = useState('') + const [error, setError] = useState(false) + const [loading, setLoading] = useState(false) + const [time, setTime] = useState(new Date()) + + useEffect(() => { + const t = setInterval(() => setTime(new Date()), 1000) + return () => clearInterval(t) + }, []) + + const submit = async (fullPin: string) => { + setLoading(true) + setError(false) + try { + const r = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin: fullPin }), + }) + if (r.ok) { + onUnlock() + } else { + setError(true) + setPin('') + setTimeout(() => setError(false), 1500) + } + } catch { + setError(true) + setPin('') + } finally { + setLoading(false) + } + } + + const handleDigit = (d: string) => { + if (pin.length >= 6) return + const next = pin + d + setPin(next) + if (next.length === 4) { + submit(next) + } + } + + const handleDelete = () => { + setPin(p => p.slice(0, -1)) + } + + const digits = ['1','2','3','4','5','6','7','8','9','','0','del'] + + return ( +
+ {/* Ambient orbs */} +
+ + {/* Time */} +
+
+ {time.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} +
+
+ {time.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })} +
+
+ + {/* Lock icon + PIN dots */} +
+
+ +
+ + {/* PIN dots */} +
+ {[0,1,2,3].map(i => ( +
+ ))} +
+ + {error && ( +
+ Неверный PIN +
+ )} +
+ + {/* Numpad */} +
+ {digits.map((d, i) => { + if (d === '') return
+ if (d === 'del') { + return ( + + ) + } + return ( + + ) + })} +
+
+ ) +} + // ————— Home Tab ————— function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: SensorData | null }) { const [todayEvents, setTodayEvents] = useState([]) @@ -109,33 +265,19 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S return (
- - {/* Top row: Weather + Sensors side by side */}
- - {/* Weather Card */} {weather && (
- {/* Background decoration */} -
+
{getWeatherIcon(weather.desc)}
- -
- Погода -
- +
Погода
{getWeatherIcon(weather.desc)}
@@ -143,22 +285,13 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
{weather.desc}
- - {/* Forecast mini */} {weather.forecast && weather.forecast.length > 0 && (
{weather.forecast.slice(0, 3).map(day => { const d = new Date(day.date) const label = d.toLocaleDateString('ru-RU', { weekday: 'short' }) return ( -
+
{label}
{getWeatherIcon(day.desc)}
{day.maxTemp}°
@@ -171,26 +304,12 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
)} - {/* Sensors Card */} {sensors && ( -
-
- Климат в квартире -
+
+
Климат в квартире
- {/* Temperature */}
-
+
@@ -198,14 +317,8 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
Температура
- - {/* Humidity */}
-
+
@@ -213,14 +326,8 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
Влажность
- - {/* PM2.5 */}
-
+
@@ -236,48 +343,22 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S )}
- {/* Today Events */} -
+
- - Сегодня - + Сегодня
- {calLoading ? (
Загрузка...
) : todayEvents.length === 0 ? ( -
- Нет событий на сегодня -
+
Нет событий на сегодня
) : (
{todayEvents.map(ev => ( -
-
+
+
-
- {ev.title} -
+
{ev.title}
{ev.allDay ? 'Весь день' : `${formatEventTime(ev.start)} — ${formatEventTime(ev.end)}`} {ev.ownerName} @@ -292,7 +373,11 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S ) } -export default function HomePage() { +function HomePageInner() { + const searchParams = useSearchParams() + const isLocked = searchParams.get('locked') === '1' + const [unlocked, setUnlocked] = useState(!isLocked) + const [tab, setTab] = useState('home') const [activeRoom, setActiveRoom] = useState('living') const [weather, setWeather] = useState(null) @@ -300,6 +385,7 @@ export default function HomePage() { const [haStates, setHaStates] = useState({}) useEffect(() => { + if (!unlocked) return const load = async () => { try { const r = await fetch('/api/weather') @@ -310,7 +396,7 @@ export default function HomePage() { load() const t = setInterval(load, 600_000) return () => clearInterval(t) - }, []) + }, [unlocked]) const loadHA = useCallback(async () => { try { @@ -322,10 +408,11 @@ export default function HomePage() { }, []) useEffect(() => { + if (!unlocked) return loadHA() const t = setInterval(loadHA, 30_000) return () => clearInterval(t) - }, [loadHA]) + }, [loadHA, unlocked]) const devicesInRoom = DEVICES_BY_ROOM[activeRoom] || [] @@ -339,29 +426,24 @@ export default function HomePage() { return undefined } + const handleLogout = async () => { + await fetch('/api/auth', { method: 'DELETE' }) + window.location.reload() + } + + if (!unlocked) { + return { setUnlocked(true); window.history.replaceState({}, '', '/') }} /> + } + return (
- {/* Ambient background */}
- -
+
{tab === 'home' && } @@ -369,28 +451,10 @@ export default function HomePage() { {tab === 'devices' && ( <> -
+
{devicesInRoom.length === 0 ? ( -
-
🏠
+
+
🏠
Устройства не добавлены
) : ( @@ -417,15 +481,7 @@ export default function HomePage() { {tab === 'calendar' && } {tab === 'settings' && ( -
+
Настройки - Скоро +
)}
) } + + +export default function HomePage() { + return ( + + + + ) +} diff --git a/components/CalendarTab.tsx b/components/CalendarTab.tsx index 969fea7..6b7b3f9 100644 --- a/components/CalendarTab.tsx +++ b/components/CalendarTab.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' -import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin, Trash2 } from 'lucide-react' +import { useState, useEffect, useMemo } from 'react' +import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin, Trash2, Eye, EyeOff } from 'lucide-react' interface CalendarEvent { id: string @@ -28,31 +28,21 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string; const [error, setError] = useState('') const inputStyle = { - padding: '12px 16px', - borderRadius: 14, - background: 'rgba(255,255,255,0.05)', - border: '1px solid rgba(255,255,255,0.08)', - color: 'var(--text-primary)', - fontSize: 14, - outline: 'none', - fontFamily: 'inherit', - transition: 'border-color 0.2s ease', + padding: '12px 16px', borderRadius: 14, + background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', + color: 'var(--text-primary)', fontSize: 14, outline: 'none', fontFamily: 'inherit', } const save = async () => { if (!title.trim()) { setError('Введите название'); return } - setSaving(true) - setError('') + setSaving(true); setError('') try { const body = { title: title.trim(), date, startTime: allDay ? null : startTime, endTime: allDay ? null : endTime, allDay } const r = await fetch('/api/calendar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) const d = await r.json() if (d.error) throw new Error(d.error) onSaved(d.event) - } catch (e: any) { - setError(e.message || 'Ошибка сохранения') - setSaving(false) - } + } catch (e: any) { setError(e.message || 'Ошибка сохранения'); setSaving(false) } } return ( @@ -62,7 +52,6 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string; Новое событие
-
setTitle(e.target.value)} placeholder="Название события" autoFocus style={inputStyle} /> setDate(e.target.value)} style={inputStyle} /> @@ -73,25 +62,15 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string;
)} {error &&
{error}
} -
@@ -110,6 +89,7 @@ export default function CalendarTab() { const [addDate, setAddDate] = useState('') const [deleting, setDeleting] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) + const [hiddenOwners, setHiddenOwners] = useState>(new Set()) useEffect(() => { setLoading(true) @@ -128,14 +108,36 @@ export default function CalendarTab() { setEvents(prev => prev.filter(e => e.id !== event.id)) setSelectedEvent(null) setConfirmDelete(false) - } catch (e: any) { - alert(e.message || 'Ошибка удаления') - } finally { - setDeleting(false) - } + } catch (e: any) { alert(e.message || 'Ошибка удаления') } + finally { setDeleting(false) } } - const upcoming = events + // Discover unique calendar owners from loaded events + const calendarOwners = useMemo(() => { + const map = new Map() + events.forEach(e => { + const existing = map.get(e.owner) + if (existing) { existing.count++ } + else { map.set(e.owner, { owner: e.owner, ownerName: e.ownerName, color: e.color, count: 1 }) } + }) + return Array.from(map.values()) + }, [events]) + + const toggleOwner = (owner: string) => { + setHiddenOwners(prev => { + const next = new Set(prev) + if (next.has(owner)) next.delete(owner) + else next.add(owner) + return next + }) + } + + const filteredEvents = useMemo(() => { + if (hiddenOwners.size === 0) return events + return events.filter(e => !hiddenOwners.has(e.owner)) + }, [events, hiddenOwners]) + + const upcoming = filteredEvents .filter(e => new Date(e.start) >= new Date()) .slice(0, 6) @@ -150,7 +152,7 @@ export default function CalendarTab() { } const getEventsForDay = (day: number) => { - return events.filter(e => { + return filteredEvents.filter(e => { const d = new Date(e.start) return d.getFullYear() === year && d.getMonth() === month && d.getDate() === day }) @@ -158,7 +160,6 @@ export default function CalendarTab() { const prevMonth = () => { if (month === 0) { setMonth(11); setYear(y => y - 1) } else setMonth(m => m - 1) } const nextMonth = () => { if (month === 11) { setMonth(0); setYear(y => y + 1) } else setMonth(m => m + 1) } - const today = new Date() return ( @@ -166,46 +167,64 @@ export default function CalendarTab() { {/* Main calendar grid */}
{/* Header */} -
+
- {MONTHS[month]} {year} -
- + +
+ {/* Calendar owner filters */} + {calendarOwners.map(cal => { + const isHidden = hiddenOwners.has(cal.owner) + return ( + + ) + })} + + +
{/* Weekday headers */} @@ -238,43 +257,26 @@ export default function CalendarTab() { } }} style={{ - borderRadius: 12, - padding: '5px 4px', - background: isToday - ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))' - : 'rgba(255,255,255,0.015)', - border: isToday - ? '1px solid rgba(129,140,248,0.3)' - : '1px solid transparent', - cursor: 'pointer', - minHeight: 56, - display: 'flex', - flexDirection: 'column', - gap: 2, + borderRadius: 12, padding: '5px 4px', + background: isToday ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))' : 'rgba(255,255,255,0.015)', + border: isToday ? '1px solid rgba(129,140,248,0.3)' : '1px solid transparent', + cursor: 'pointer', minHeight: 56, + display: 'flex', flexDirection: 'column', gap: 2, transition: 'all 0.2s ease', }} > {day} {dayEvents.slice(0, 2).map(e => ( -
{ ev.stopPropagation(); setSelectedEvent(e) }} - style={{ - fontSize: 10, fontWeight: 600, - background: e.color + '1a', - border: `1px solid ${e.color}30`, - color: e.color, - borderRadius: 6, - padding: '2px 5px', - overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - cursor: 'pointer', - }} - > +
{ ev.stopPropagation(); setSelectedEvent(e) }} style={{ + fontSize: 10, fontWeight: 600, + background: e.color + '1a', border: `1px solid ${e.color}30`, + color: e.color, borderRadius: 6, padding: '2px 5px', + overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer', + }}> {e.title}
))} @@ -287,15 +289,11 @@ export default function CalendarTab() {
- {/* Right panel: upcoming events */} + {/* Right panel */}
Ближайшие
{upcoming.length === 0 && !loading && ( @@ -304,24 +302,13 @@ export default function CalendarTab() { {upcoming.map(e => { const d = new Date(e.start) return ( -
setSelectedEvent(e)} - style={{ - borderRadius: 16, - padding: '14px 14px', - background: `${e.color}0c`, - border: `1px solid ${e.color}1a`, - cursor: 'pointer', - transition: 'all 0.25s ease', - }} - > +
setSelectedEvent(e)} style={{ + borderRadius: 16, padding: '14px', + background: `${e.color}0c`, border: `1px solid ${e.color}1a`, + cursor: 'pointer', transition: 'all 0.25s ease', + }}>
-
+
{d.getDate()}
{MONTHS[d.getMonth()].slice(0, 3)}
@@ -340,11 +327,9 @@ export default function CalendarTab() { {selectedEvent && (
{ setSelectedEvent(null); setConfirmDelete(false) }}>
e.stopPropagation()}>
@@ -356,78 +341,45 @@ export default function CalendarTab() {
-
-
+
- {selectedEvent.allDay - ? 'Весь день' - : `${new Date(selectedEvent.start).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} — ${new Date(selectedEvent.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` - } + {selectedEvent.allDay ? 'Весь день' : `${new Date(selectedEvent.start).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} — ${new Date(selectedEvent.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`}
{selectedEvent.location && ( -
+
{selectedEvent.location}
)} {selectedEvent.description && (
{selectedEvent.description}
)} - - {/* Delete button */}
{!confirmDelete ? ( - ) : (
- -
diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..5e33f80 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import * as crypto from 'crypto' + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + // Allow auth API and static assets + if ( + pathname.startsWith('/api/auth') || + pathname.startsWith('/_next') || + pathname.startsWith('/favicon') || + pathname === '/manifest.json' + ) { + return NextResponse.next() + } + + const token = request.cookies.get('auth_token')?.value + const pin = process.env.APP_PIN || '1234' + const secret = process.env.APP_SECRET || 'smart-home-default-secret-change-me' + const expectedToken = crypto.createHmac('sha256', secret).update(pin).digest('hex') + + if (token !== expectedToken) { + // For API routes, return 401 + if (pathname.startsWith('/api/')) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + // For page requests, rewrite to show login (handled client-side) + const url = request.nextUrl.clone() + url.searchParams.set('locked', '1') + return NextResponse.rewrite(url) + } + + return NextResponse.next() +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|manifest.json).*)'], +}