설정 화면 정리
This commit is contained in:
@@ -90,6 +90,7 @@ function mapUserRow(row) {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
nickname: row.nickname || '',
|
||||
nicknameUpdatedAt: Number(row.nickname_updated_at || 0),
|
||||
emailVerified: row.email_verified == null ? true : !!row.email_verified,
|
||||
isAdmin: !!row.is_admin,
|
||||
avatarSrc: row.avatar_src || '',
|
||||
@@ -412,6 +413,7 @@ async function ensureSchema() {
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
nickname VARCHAR(80) NOT NULL DEFAULT '',
|
||||
nickname_updated_at BIGINT NOT NULL DEFAULT 0,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 1,
|
||||
is_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
@@ -494,6 +496,8 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname_updated_at BIGINT NOT NULL DEFAULT 0 AFTER nickname`)
|
||||
await query(`UPDATE users SET nickname_updated_at = created_at WHERE nickname_updated_at = 0`)
|
||||
await query(`ALTER TABLE topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`)
|
||||
await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
|
||||
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
|
||||
@@ -696,7 +700,7 @@ async function countUsers() {
|
||||
|
||||
async function findUserByEmail(email) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
'SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
)
|
||||
const row = rows[0]
|
||||
@@ -710,7 +714,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
const rows = excludeUserId
|
||||
? await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
|
||||
LIMIT 1
|
||||
@@ -719,7 +723,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
)
|
||||
: await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
|
||||
LIMIT 1
|
||||
@@ -733,7 +737,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
|
||||
async function findUserById(id) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
'SELECT id, email, nickname, nickname_updated_at, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapUserRow(rows[0])
|
||||
@@ -743,10 +747,10 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
|
||||
[id, email, nickname || '', createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
|
||||
)
|
||||
return findUserById(id)
|
||||
}
|
||||
@@ -875,11 +879,21 @@ async function consumePasswordResetToken(tokenId) {
|
||||
await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
|
||||
}
|
||||
|
||||
async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||
async function updateUserProfile({ id, nickname, avatarSrc, touchNicknameUpdatedAt = false }) {
|
||||
if (typeof avatarSrc === 'string') {
|
||||
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET nickname = ?, avatar_src = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END
|
||||
WHERE id = ?`,
|
||||
[nickname || '', avatarSrc, touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id]
|
||||
)
|
||||
} else {
|
||||
await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET nickname = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END
|
||||
WHERE id = ?`,
|
||||
[nickname || '', touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id]
|
||||
)
|
||||
}
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const { isReservedNickname } = require('../lib/user-validation')
|
||||
const router = express.Router()
|
||||
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000
|
||||
const NICKNAME_CHANGE_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -60,7 +61,7 @@ const changePasswordSchema = z.object({
|
||||
})
|
||||
|
||||
const profileSchema = z.object({
|
||||
nickname: z.string().trim().min(1).max(40),
|
||||
nickname: z.string().trim().min(2).max(40),
|
||||
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
||||
})
|
||||
|
||||
@@ -87,6 +88,8 @@ async function serializeUser(user) {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
nicknameUpdatedAt: user.nicknameUpdatedAt || 0,
|
||||
nicknameChangeAvailableAt: (user.nicknameUpdatedAt || 0) + NICKNAME_CHANGE_INTERVAL_MS,
|
||||
isAdmin: !!user.isAdmin,
|
||||
isPrimaryAdmin,
|
||||
isOperator: !!user.isAdmin && !isPrimaryAdmin,
|
||||
@@ -358,8 +361,18 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
||||
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' })
|
||||
const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id)
|
||||
const normalizedNickname = parsed.data.nickname.trim()
|
||||
const nicknameChanged = normalizedNickname !== (user.nickname || '').trim()
|
||||
|
||||
if (isReservedNickname(normalizedNickname)) return res.status(400).json({ error: 'nickname_reserved' })
|
||||
if (nicknameChanged && user.nicknameUpdatedAt && Date.now() < user.nicknameUpdatedAt + NICKNAME_CHANGE_INTERVAL_MS) {
|
||||
return res.status(429).json({
|
||||
error: 'nickname_change_locked',
|
||||
nicknameChangeAvailableAt: user.nicknameUpdatedAt + NICKNAME_CHANGE_INTERVAL_MS,
|
||||
})
|
||||
}
|
||||
|
||||
const nicknameExists = await findUserByNickname(normalizedNickname, user.id)
|
||||
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
|
||||
|
||||
const optimized = req.file
|
||||
@@ -377,8 +390,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
||||
const updated = await updateUserProfile({
|
||||
id: user.id,
|
||||
nickname: parsed.data.nickname,
|
||||
nickname: normalizedNickname,
|
||||
avatarSrc: nextAvatarSrc,
|
||||
touchNicknameUpdatedAt: nicknameChanged,
|
||||
})
|
||||
|
||||
res.json({ user: await serializeUser(updated) })
|
||||
|
||||
Reference in New Issue
Block a user