Compare commits
3 Commits
533cd2a0bd
...
fix/save-o
| Author | SHA1 | Date | |
|---|---|---|---|
| 42aefaab17 | |||
| dcf516f2de | |||
| c8de1ad229 |
@@ -240,6 +240,8 @@ def init_db():
|
|||||||
c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER")
|
c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER")
|
||||||
if "en_name" not in cols:
|
if "en_name" not in cols:
|
||||||
c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''")
|
c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''")
|
||||||
|
if "en_oils" not in cols:
|
||||||
|
c.execute("ALTER TABLE recipes ADD COLUMN en_oils TEXT DEFAULT '{}'")
|
||||||
|
|
||||||
# Seed admin user if no users exist
|
# Seed admin user if no users exist
|
||||||
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class RecipeIn(BaseModel):
|
|||||||
class RecipeUpdate(BaseModel):
|
class RecipeUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
en_name: Optional[str] = None
|
en_name: Optional[str] = None
|
||||||
|
en_oils: Optional[str] = None
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
ingredients: Optional[list[IngredientIn]] = None
|
ingredients: Optional[list[IngredientIn]] = None
|
||||||
tags: Optional[list[str]] = None
|
tags: Optional[list[str]] = None
|
||||||
@@ -309,7 +310,7 @@ def symptom_search(body: dict, user=Depends(get_current_user)):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
# Search in recipe names
|
# Search in recipe names
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id"
|
"SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes ORDER BY id"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
exact = []
|
exact = []
|
||||||
related = []
|
related = []
|
||||||
@@ -698,6 +699,7 @@ def _recipe_to_dict(conn, row):
|
|||||||
"id": rid,
|
"id": rid,
|
||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
"en_name": row["en_name"] if "en_name" in row.keys() else "",
|
"en_name": row["en_name"] if "en_name" in row.keys() else "",
|
||||||
|
"en_oils": row["en_oils"] if "en_oils" in row.keys() else "{}",
|
||||||
"note": row["note"],
|
"note": row["note"],
|
||||||
"owner_id": row["owner_id"],
|
"owner_id": row["owner_id"],
|
||||||
"owner_name": (owner["display_name"] or owner["username"]) if owner else None,
|
"owner_name": (owner["display_name"] or owner["username"]) if owner else None,
|
||||||
@@ -712,19 +714,19 @@ def list_recipes(user=Depends(get_current_user)):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
# Admin sees all; others see admin-owned (adopted) + their own
|
# Admin sees all; others see admin-owned (adopted) + their own
|
||||||
if user["role"] == "admin":
|
if user["role"] == "admin":
|
||||||
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall()
|
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes ORDER BY id").fetchall()
|
||||||
else:
|
else:
|
||||||
admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
||||||
admin_id = admin["id"] if admin else 1
|
admin_id = admin["id"] if admin else 1
|
||||||
user_id = user.get("id")
|
user_id = user.get("id")
|
||||||
if user_id:
|
if user_id:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id",
|
"SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id",
|
||||||
(admin_id, user_id)
|
(admin_id, user_id)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE owner_id = ? ORDER BY id",
|
"SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes WHERE owner_id = ? ORDER BY id",
|
||||||
(admin_id,)
|
(admin_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
result = [_recipe_to_dict(conn, r) for r in rows]
|
result = [_recipe_to_dict(conn, r) for r in rows]
|
||||||
@@ -735,7 +737,7 @@ def list_recipes(user=Depends(get_current_user)):
|
|||||||
@app.get("/api/recipes/{recipe_id}")
|
@app.get("/api/recipes/{recipe_id}")
|
||||||
def get_recipe(recipe_id: int):
|
def get_recipe(recipe_id: int):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
row = conn.execute("SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(404, "Recipe not found")
|
raise HTTPException(404, "Recipe not found")
|
||||||
@@ -808,6 +810,8 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
|
|||||||
c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id))
|
c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id))
|
||||||
if update.en_name is not None:
|
if update.en_name is not None:
|
||||||
c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (update.en_name, recipe_id))
|
c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (update.en_name, recipe_id))
|
||||||
|
if update.en_oils is not None:
|
||||||
|
c.execute("UPDATE recipes SET en_oils = ? WHERE id = ?", (update.en_oils, recipe_id))
|
||||||
if update.ingredients is not None:
|
if update.ingredients is not None:
|
||||||
c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
|
c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
|
||||||
for ing in update.ingredients:
|
for ing in update.ingredients:
|
||||||
@@ -837,7 +841,7 @@ def delete_recipe(recipe_id: int, user=Depends(get_current_user)):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = _check_recipe_permission(conn, recipe_id, user)
|
row = _check_recipe_permission(conn, recipe_id, user)
|
||||||
# Save full snapshot for undo
|
# Save full snapshot for undo
|
||||||
full = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
full = conn.execute("SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||||
snapshot = _recipe_to_dict(conn, full)
|
snapshot = _recipe_to_dict(conn, full)
|
||||||
log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"],
|
log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"],
|
||||||
json.dumps(snapshot, ensure_ascii=False))
|
json.dumps(snapshot, ensure_ascii=False))
|
||||||
@@ -1340,7 +1344,7 @@ def recipes_by_inventory(user=Depends(get_current_user)):
|
|||||||
if not inv:
|
if not inv:
|
||||||
conn.close()
|
conn.close()
|
||||||
return []
|
return []
|
||||||
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall()
|
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name, en_oils FROM recipes ORDER BY id").fetchall()
|
||||||
result = []
|
result = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
recipe = _recipe_to_dict(conn, r)
|
recipe = _recipe_to_dict(conn, r)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
>
|
>
|
||||||
<template v-if="auth.isLoggedIn">
|
<template v-if="auth.isLoggedIn">
|
||||||
👤 {{ auth.user.display_name || auth.user.username }}
|
👤 {{ auth.user.display_name || auth.user.username }}
|
||||||
<span style="font-size:10px;background:rgba(255,255,255,0.2);padding:2px 7px;border-radius:8px;margin:0 3px;vertical-align:middle;font-weight:400;letter-spacing:0">{{ roleLabel }}</span>
|
|
||||||
▾
|
▾
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -70,7 +69,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useOilsStore } from './stores/oils'
|
import { useOilsStore } from './stores/oils'
|
||||||
@@ -88,11 +87,6 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const showUserMenu = ref(false)
|
const showUserMenu = ref(false)
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
|
||||||
const map = { admin: '管理员', senior_editor: '高级编辑', editor: '编辑', viewer: '查看者' }
|
|
||||||
return map[auth.user.role] || '查看者'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync ui.currentSection from route on load and navigation
|
// Sync ui.currentSection from route on load and navigation
|
||||||
const routeToSection = { '/': 'search', '/manage': 'manage', '/inventory': 'inventory', '/oils': 'oils', '/projects': 'projects', '/mydiary': 'mydiary', '/audit': 'audit', '/bugs': 'bugs', '/users': 'users' }
|
const routeToSection = { '/': 'search', '/manage': 'manage', '/inventory': 'inventory', '/oils': 'oils', '/projects': 'projects', '/mydiary': 'mydiary', '/audit': 'audit', '/bugs': 'bugs', '/users': 'users' }
|
||||||
watch(() => route.path, (path) => {
|
watch(() => route.path, (path) => {
|
||||||
|
|||||||
@@ -576,11 +576,12 @@ function copyText() {
|
|||||||
|
|
||||||
async function applyTranslation() {
|
async function applyTranslation() {
|
||||||
showTranslationEditor.value = false
|
showTranslationEditor.value = false
|
||||||
// Persist en_name to backend
|
// Persist en_name and en_oils to backend
|
||||||
if (recipe.value._id && customRecipeNameEn.value) {
|
if (recipe.value._id) {
|
||||||
try {
|
try {
|
||||||
await api.put(`/api/recipes/${recipe.value._id}`, {
|
await api.put(`/api/recipes/${recipe.value._id}`, {
|
||||||
en_name: customRecipeNameEn.value,
|
en_name: customRecipeNameEn.value,
|
||||||
|
en_oils: JSON.stringify(customOilNameEn.value),
|
||||||
version: recipe.value._version,
|
version: recipe.value._version,
|
||||||
})
|
})
|
||||||
ui.showToast('翻译已保存')
|
ui.showToast('翻译已保存')
|
||||||
@@ -708,9 +709,10 @@ onMounted(() => {
|
|||||||
editIngredients.value = (r.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
|
editIngredients.value = (r.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
|
||||||
// Init translation defaults
|
// Init translation defaults
|
||||||
customRecipeNameEn.value = r.en_name || recipeNameEn(r.name)
|
customRecipeNameEn.value = r.en_name || recipeNameEn(r.name)
|
||||||
|
const savedOilMap = r.en_oils ? (() => { try { return JSON.parse(r.en_oils) } catch { return {} } })() : {}
|
||||||
const enMap = {}
|
const enMap = {}
|
||||||
;(r.ingredients || []).forEach(ing => {
|
;(r.ingredients || []).forEach(ing => {
|
||||||
enMap[ing.oil] = oilEn(ing.oil) || ing.oil
|
enMap[ing.oil] = savedOilMap[ing.oil] || oilEn(ing.oil) || ing.oil
|
||||||
})
|
})
|
||||||
customOilNameEn.value = enMap
|
customOilNameEn.value = enMap
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
<div class="usermenu-overlay" @click.self="$emit('close')">
|
<div class="usermenu-overlay" @click.self="$emit('close')">
|
||||||
<div class="usermenu-card">
|
<div class="usermenu-card">
|
||||||
<div class="usermenu-name">{{ auth.user.display_name || auth.user.username }}</div>
|
<div class="usermenu-name">{{ auth.user.display_name || auth.user.username }}</div>
|
||||||
<div class="usermenu-role">
|
|
||||||
<span class="role-badge">{{ roleLabel }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="usermenu-actions">
|
<div class="usermenu-actions">
|
||||||
<button class="usermenu-btn" @click="goMyDiary">
|
<button class="usermenu-btn" @click="goMyDiary">
|
||||||
@@ -71,16 +68,6 @@ const bugContent = ref('')
|
|||||||
|
|
||||||
const unreadCount = computed(() => notifications.value.filter(n => !n.is_read).length)
|
const unreadCount = computed(() => notifications.value.filter(n => !n.is_read).length)
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
|
||||||
const map = {
|
|
||||||
admin: '管理员',
|
|
||||||
senior_editor: '高级编辑',
|
|
||||||
editor: '编辑',
|
|
||||||
viewer: '查看者',
|
|
||||||
}
|
|
||||||
return map[auth.user.role] || auth.user.role
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTime(d) {
|
function formatTime(d) {
|
||||||
if (!d) return ''
|
if (!d) return ''
|
||||||
return new Date(d + 'Z').toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
return new Date(d + 'Z').toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -136,7 +123,7 @@ function handleLogout() {
|
|||||||
auth.logout()
|
auth.logout()
|
||||||
ui.showToast('已退出登录')
|
ui.showToast('已退出登录')
|
||||||
emit('close')
|
emit('close')
|
||||||
window.location.reload()
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadNotifications)
|
onMounted(loadNotifications)
|
||||||
@@ -162,13 +149,7 @@ onMounted(loadNotifications)
|
|||||||
z-index: 4001;
|
z-index: 4001;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 4px; }
|
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 14px; }
|
||||||
.usermenu-role { margin-bottom: 14px; }
|
|
||||||
.role-badge {
|
|
||||||
display: inline-block; font-size: 11px; padding: 2px 10px;
|
|
||||||
border-radius: 8px; background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
|
||||||
color: #4a9d7e; font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usermenu-actions { display: flex; flex-direction: column; gap: 4px; }
|
.usermenu-actions { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.usermenu-btn {
|
.usermenu-btn {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||||||
_version: r._version ?? r.version ?? 1,
|
_version: r._version ?? r.version ?? 1,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
en_name: r.en_name ?? '',
|
en_name: r.en_name ?? '',
|
||||||
|
en_oils: r.en_oils ?? '{}',
|
||||||
note: r.note ?? '',
|
note: r.note ?? '',
|
||||||
tags: r.tags ?? [],
|
tags: r.tags ?? [],
|
||||||
ingredients: (r.ingredients ?? []).map((ing) => ({
|
ingredients: (r.ingredients ?? []).map((ing) => ({
|
||||||
|
|||||||
@@ -130,14 +130,12 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>我的二维码图片</label>
|
<label>我的二维码图片</label>
|
||||||
<div class="qr-preview">
|
<div class="upload-area" @click="triggerUpload('qr')">
|
||||||
<div class="upload-area qr-upload-area" @click="triggerUpload('qr')">
|
<img v-if="brandQrImage" :src="brandQrImage" class="upload-preview qr-upload-preview" />
|
||||||
<img v-if="brandQrImage" :src="brandQrImage" class="qr-img" />
|
<span v-else class="upload-hint">📲 点击上传二维码图片</span>
|
||||||
<span v-else class="upload-hint">📲 点击上传</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<input ref="qrInput" type="file" accept="image/*" style="display:none" @change="handleUpload('qr', $event)" />
|
<input ref="qrInput" type="file" accept="image/*" style="display:none" @change="handleUpload('qr', $event)" />
|
||||||
<div class="field-hint" style="text-align:center">上传后将显示在配方卡片右下角</div>
|
<div class="field-hint">上传后将显示在配方卡片右下角</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -176,10 +174,6 @@
|
|||||||
<div class="form-static">{{ auth.user.username }}</div>
|
<div class="form-static">{{ auth.user.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>角色</label>
|
|
||||||
<div class="role-badge-display">{{ roleLabel }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
@@ -218,7 +212,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useOilsStore } from '../stores/oils'
|
import { useOilsStore } from '../stores/oils'
|
||||||
@@ -258,16 +252,6 @@ const newPassword = ref('')
|
|||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const businessReason = ref('')
|
const businessReason = ref('')
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
|
||||||
const roles = {
|
|
||||||
admin: '管理员',
|
|
||||||
senior_editor: '高级编辑',
|
|
||||||
editor: '编辑',
|
|
||||||
viewer: '查看者',
|
|
||||||
}
|
|
||||||
return roles[auth.user.role] || auth.user.role
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await diaryStore.loadDiary()
|
await diaryStore.loadDiary()
|
||||||
displayName.value = auth.user.display_name || ''
|
displayName.value = auth.user.display_name || ''
|
||||||
@@ -812,28 +796,6 @@ async function applyBusiness() {
|
|||||||
color: #6b6375;
|
color: #6b6375;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-badge-display {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 3px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
|
||||||
color: #4a9d7e;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-preview {
|
|
||||||
margin-top: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-img {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1.5px solid #e5e4e7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-area {
|
.upload-area {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
@@ -851,6 +813,18 @@ async function applyBusiness() {
|
|||||||
border-color: #7ec6a4;
|
border-color: #7ec6a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qr-preview {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-img {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid #e5e4e7;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-preview {
|
.upload-preview {
|
||||||
max-width: 80px;
|
max-width: 80px;
|
||||||
max-height: 80px;
|
max-height: 80px;
|
||||||
@@ -862,11 +836,9 @@ async function applyBusiness() {
|
|||||||
max-height: 100px;
|
max-height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-upload-area {
|
.qr-upload-preview {
|
||||||
width: 120px;
|
max-width: 120px;
|
||||||
height: 120px;
|
max-height: 120px;
|
||||||
min-height: unset;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-hint {
|
.field-hint {
|
||||||
|
|||||||
Reference in New Issue
Block a user