Compare commits
No commits in common. "fd244781e1ee49371d6a69ace99a1b769c332276" and "bc93f487115de46c0986b668d8767d39aa7b92a4" have entirely different histories.
fd244781e1
...
bc93f48711
@ -1,178 +0,0 @@
|
|||||||
# Ticket Summary - AI Disclaimer Modal
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Added a mandatory AI disclaimer warning that users must accept before creating new issues. This ensures users understand the risks and limitations of AI-assisted triage and accept responsibility for any actions taken based on AI recommendations.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Disclaimer appears automatically on first visit to New Issue page
|
|
||||||
- [x] Modal blocks interaction with page until user accepts or cancels
|
|
||||||
- [x] Acceptance is persisted across sessions
|
|
||||||
- [x] Clear, professional warning about AI limitations
|
|
||||||
- [x] Covers key risks: mistakes, hallucinations, incorrect commands
|
|
||||||
- [x] Emphasizes user responsibility and accountability
|
|
||||||
- [x] Includes best practices for safe AI usage
|
|
||||||
- [x] Cancel button returns user to dashboard
|
|
||||||
- [x] Modal re-appears if user tries to create issue without accepting
|
|
||||||
|
|
||||||
## Work Implemented
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
**File:** `src/pages/NewIssue/index.tsx`
|
|
||||||
|
|
||||||
1. **Modal Component:**
|
|
||||||
- Full-screen overlay with backdrop
|
|
||||||
- Centered modal dialog (max-width 2xl)
|
|
||||||
- Scrollable content area for long disclaimer text
|
|
||||||
- Professional styling with proper contrast
|
|
||||||
|
|
||||||
2. **Disclaimer Content:**
|
|
||||||
- **Header:** "AI-Assisted Triage Disclaimer"
|
|
||||||
- **Warning Section** (red background):
|
|
||||||
- AI can provide incorrect, incomplete, or outdated information
|
|
||||||
- AI can hallucinate false information
|
|
||||||
- Recommendations may not apply to specific environments
|
|
||||||
- Commands may have unintended consequences (data loss, downtime, security issues)
|
|
||||||
- **Responsibility Section** (yellow background):
|
|
||||||
- User is solely responsible for all actions taken
|
|
||||||
- Must verify AI suggestions against documentation
|
|
||||||
- Must test in non-production first
|
|
||||||
- Must understand commands before executing
|
|
||||||
- Must have backups and rollback plans
|
|
||||||
- **Best Practices:**
|
|
||||||
- Treat AI as starting point, not definitive answer
|
|
||||||
- Consult senior engineers for critical systems
|
|
||||||
- Review AI content for accuracy
|
|
||||||
- Maintain change control processes
|
|
||||||
- Document decisions
|
|
||||||
- **Legal acknowledgment**
|
|
||||||
|
|
||||||
3. **State Management:**
|
|
||||||
- `showDisclaimer` state controls modal visibility
|
|
||||||
- `useEffect` hook checks localStorage on page load
|
|
||||||
- Acceptance stored as `tftsr-ai-disclaimer-accepted` in localStorage
|
|
||||||
- Persists across sessions and app restarts
|
|
||||||
|
|
||||||
4. **User Flow:**
|
|
||||||
- User visits New Issue → Modal appears
|
|
||||||
- User clicks "I Understand and Accept" → Modal closes, localStorage updated
|
|
||||||
- User clicks "Cancel" → Navigates back to dashboard
|
|
||||||
- User tries to create issue without accepting → Modal re-appears
|
|
||||||
- After acceptance, modal never shows again (unless localStorage cleared)
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
|
|
||||||
**Storage:** `localStorage.getItem("tftsr-ai-disclaimer-accepted")`
|
|
||||||
- Key: `tftsr-ai-disclaimer-accepted`
|
|
||||||
- Value: `"true"` when accepted
|
|
||||||
- Scope: Per-browser, persists across sessions
|
|
||||||
|
|
||||||
**Validation Points:**
|
|
||||||
1. Page load - Shows modal if not accepted
|
|
||||||
2. "Start Triage" button click - Re-checks acceptance before proceeding
|
|
||||||
|
|
||||||
**Styling:**
|
|
||||||
- Dark overlay: `bg-black/50`
|
|
||||||
- Modal: `bg-background` with border and shadow
|
|
||||||
- Red warning box: `bg-destructive/10 border-destructive/20`
|
|
||||||
- Yellow responsibility box: `bg-yellow-500/10 border-yellow-500/20`
|
|
||||||
- Scrollable content: `max-h-[60vh] overflow-y-auto`
|
|
||||||
|
|
||||||
## Testing Needed
|
|
||||||
|
|
||||||
### Manual Testing
|
|
||||||
|
|
||||||
1. **First Visit Flow:**
|
|
||||||
- [ ] Navigate to New Issue page
|
|
||||||
- [ ] Verify modal appears automatically
|
|
||||||
- [ ] Verify page content is blocked/dimmed
|
|
||||||
- [ ] Verify modal is scrollable
|
|
||||||
- [ ] Verify all sections are visible and readable
|
|
||||||
|
|
||||||
2. **Acceptance Flow:**
|
|
||||||
- [ ] Click "I Understand and Accept"
|
|
||||||
- [ ] Verify modal closes
|
|
||||||
- [ ] Verify can now create issues
|
|
||||||
- [ ] Refresh page
|
|
||||||
- [ ] Verify modal does NOT re-appear
|
|
||||||
|
|
||||||
3. **Cancel Flow:**
|
|
||||||
- [ ] Clear localStorage: `localStorage.removeItem("tftsr-ai-disclaimer-accepted")`
|
|
||||||
- [ ] Go to New Issue page
|
|
||||||
- [ ] Click "Cancel" button
|
|
||||||
- [ ] Verify redirected to dashboard
|
|
||||||
- [ ] Go back to New Issue page
|
|
||||||
- [ ] Verify modal appears again
|
|
||||||
|
|
||||||
4. **Rejection Flow:**
|
|
||||||
- [ ] Clear localStorage
|
|
||||||
- [ ] Go to New Issue page
|
|
||||||
- [ ] Close modal without accepting (if possible)
|
|
||||||
- [ ] Fill in issue details
|
|
||||||
- [ ] Click "Start Triage"
|
|
||||||
- [ ] Verify modal re-appears before issue creation
|
|
||||||
|
|
||||||
5. **Visual Testing:**
|
|
||||||
- [ ] Test in light theme - verify text contrast
|
|
||||||
- [ ] Test in dark theme - verify text contrast
|
|
||||||
- [ ] Test on mobile viewport - verify modal fits
|
|
||||||
- [ ] Test with very long issue title - verify modal remains on top
|
|
||||||
- [ ] Verify warning colors are distinct (red vs yellow boxes)
|
|
||||||
|
|
||||||
6. **Accessibility:**
|
|
||||||
- [ ] Verify modal can be navigated with keyboard
|
|
||||||
- [ ] Verify "Accept" button can be focused and activated with Enter
|
|
||||||
- [ ] Verify "Cancel" button can be focused
|
|
||||||
- [ ] Verify modal traps focus (Tab doesn't leave modal)
|
|
||||||
- [ ] Verify text is readable at different zoom levels
|
|
||||||
|
|
||||||
### Browser Testing
|
|
||||||
|
|
||||||
Test localStorage persistence across:
|
|
||||||
- [ ] Chrome/Edge
|
|
||||||
- [ ] Firefox
|
|
||||||
- [ ] Safari
|
|
||||||
- [ ] Browser restart
|
|
||||||
- [ ] Tab close and reopen
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- [ ] Multiple browser tabs - verify acceptance in one tab reflects in others on reload
|
|
||||||
- [ ] Incognito/private browsing - verify modal appears every session
|
|
||||||
- [ ] localStorage quota exceeded - verify graceful degradation
|
|
||||||
- [ ] Disabled JavaScript - app won't work, but no crashes
|
|
||||||
- [ ] Fast double-click on Accept - verify no duplicate localStorage writes
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
**Disclaimer Bypass Risk:**
|
|
||||||
Users could theoretically bypass the disclaimer by:
|
|
||||||
1. Manually setting localStorage: `localStorage.setItem("tftsr-ai-disclaimer-accepted", "true")`
|
|
||||||
2. Using browser dev tools
|
|
||||||
|
|
||||||
**Mitigation:** This is acceptable because:
|
|
||||||
- The disclaimer is for liability protection, not security
|
|
||||||
- Users who bypass it are technical enough to understand the risks
|
|
||||||
- The disclaimer is shown prominently and is hard to miss accidentally
|
|
||||||
- Acceptance is logged client-side (could be enhanced to log server-side for audit)
|
|
||||||
|
|
||||||
**Future Enhancement:**
|
|
||||||
- Log acceptance event to backend with timestamp
|
|
||||||
- Store acceptance in database tied to user session
|
|
||||||
- Require periodic re-acceptance (e.g., every 90 days)
|
|
||||||
- Add version tracking to re-show on disclaimer updates
|
|
||||||
|
|
||||||
## Legal Notes
|
|
||||||
|
|
||||||
This disclaimer should be reviewed by legal counsel to ensure:
|
|
||||||
- Adequate liability protection
|
|
||||||
- Compliance with jurisdiction-specific requirements
|
|
||||||
- Appropriate language for organizational use
|
|
||||||
- Clear "Use at your own risk" messaging
|
|
||||||
|
|
||||||
**Recommended additions (by legal):**
|
|
||||||
- Add version number/date to disclaimer
|
|
||||||
- Log acceptance with timestamp for audit trail
|
|
||||||
- Consider adding "This is an experimental tool" if applicable
|
|
||||||
- Add specific disclaimer for any regulated environments (healthcare, finance, etc.)
|
|
||||||
@ -127,29 +127,6 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
|
|||||||
content='issues', content_rowid='rowid'
|
content='issues', content_rowid='rowid'
|
||||||
);",
|
);",
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"011_create_integrations",
|
|
||||||
"CREATE TABLE IF NOT EXISTS credentials (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
service TEXT NOT NULL CHECK(service IN ('confluence','servicenow','azuredevops')),
|
|
||||||
token_hash TEXT NOT NULL,
|
|
||||||
encrypted_token TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
expires_at TEXT,
|
|
||||||
UNIQUE(service)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS integration_config (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
service TEXT NOT NULL CHECK(service IN ('confluence','servicenow','azuredevops')),
|
|
||||||
base_url TEXT NOT NULL,
|
|
||||||
username TEXT,
|
|
||||||
project_name TEXT,
|
|
||||||
space_key TEXT,
|
|
||||||
auto_create_enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(service)
|
|
||||||
);",
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, sql) in migrations {
|
for (name, sql) in migrations {
|
||||||
@ -174,214 +151,3 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
|
|
||||||
fn setup_test_db() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
|
||||||
run_migrations(&conn).unwrap();
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_create_credentials_table() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
|
|
||||||
// Verify table exists
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='credentials'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 1);
|
|
||||||
|
|
||||||
// Verify columns
|
|
||||||
let mut stmt = conn.prepare("PRAGMA table_info(credentials)").unwrap();
|
|
||||||
let columns: Vec<String> = stmt
|
|
||||||
.query_map([], |row| row.get::<_, String>(1))
|
|
||||||
.unwrap()
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(columns.contains(&"id".to_string()));
|
|
||||||
assert!(columns.contains(&"service".to_string()));
|
|
||||||
assert!(columns.contains(&"token_hash".to_string()));
|
|
||||||
assert!(columns.contains(&"encrypted_token".to_string()));
|
|
||||||
assert!(columns.contains(&"created_at".to_string()));
|
|
||||||
assert!(columns.contains(&"expires_at".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_create_integration_config_table() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
|
|
||||||
// Verify table exists
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='integration_config'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 1);
|
|
||||||
|
|
||||||
// Verify columns
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("PRAGMA table_info(integration_config)")
|
|
||||||
.unwrap();
|
|
||||||
let columns: Vec<String> = stmt
|
|
||||||
.query_map([], |row| row.get::<_, String>(1))
|
|
||||||
.unwrap()
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(columns.contains(&"id".to_string()));
|
|
||||||
assert!(columns.contains(&"service".to_string()));
|
|
||||||
assert!(columns.contains(&"base_url".to_string()));
|
|
||||||
assert!(columns.contains(&"username".to_string()));
|
|
||||||
assert!(columns.contains(&"project_name".to_string()));
|
|
||||||
assert!(columns.contains(&"space_key".to_string()));
|
|
||||||
assert!(columns.contains(&"auto_create_enabled".to_string()));
|
|
||||||
assert!(columns.contains(&"updated_at".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_store_and_retrieve_credential() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
|
|
||||||
// Insert credential
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
rusqlite::params![
|
|
||||||
"test-id",
|
|
||||||
"confluence",
|
|
||||||
"test_hash",
|
|
||||||
"encrypted_test",
|
|
||||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Retrieve
|
|
||||||
let (service, token_hash): (String, String) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT service, token_hash FROM credentials WHERE service = ?1",
|
|
||||||
["confluence"],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(service, "confluence");
|
|
||||||
assert_eq!(token_hash, "test_hash");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_store_and_retrieve_integration_config() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
|
|
||||||
// Insert config
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO integration_config (id, service, base_url, space_key, auto_create_enabled, updated_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params![
|
|
||||||
"test-config-id",
|
|
||||||
"confluence",
|
|
||||||
"https://example.atlassian.net",
|
|
||||||
"DEV",
|
|
||||||
1,
|
|
||||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Retrieve
|
|
||||||
let (service, base_url, space_key, auto_create): (String, String, String, i32) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT service, base_url, space_key, auto_create_enabled FROM integration_config WHERE service = ?1",
|
|
||||||
["confluence"],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(service, "confluence");
|
|
||||||
assert_eq!(base_url, "https://example.atlassian.net");
|
|
||||||
assert_eq!(space_key, "DEV");
|
|
||||||
assert_eq!(auto_create, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_service_uniqueness_constraint() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
|
|
||||||
// Insert first credential
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
rusqlite::params![
|
|
||||||
"test-id-1",
|
|
||||||
"confluence",
|
|
||||||
"hash1",
|
|
||||||
"token1",
|
|
||||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Try to insert duplicate service - should fail
|
|
||||||
let result = conn.execute(
|
|
||||||
"INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
rusqlite::params![
|
|
||||||
"test-id-2",
|
|
||||||
"confluence",
|
|
||||||
"hash2",
|
|
||||||
"token2",
|
|
||||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_migration_tracking() {
|
|
||||||
let conn = setup_test_db();
|
|
||||||
|
|
||||||
// Verify migration 011 was applied
|
|
||||||
let applied: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
|
|
||||||
["011_create_integrations"],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(applied, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_migrations_idempotent() {
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
|
||||||
|
|
||||||
// Run migrations twice
|
|
||||||
run_migrations(&conn).unwrap();
|
|
||||||
run_migrations(&conn).unwrap();
|
|
||||||
|
|
||||||
// Verify migration was only recorded once
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
|
|
||||||
["011_create_integrations"],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(count, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -340,55 +340,3 @@ pub struct SettingRecord {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Integrations ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Credential {
|
|
||||||
pub id: String,
|
|
||||||
pub service: String,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub encrypted_token: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub expires_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Credential {
|
|
||||||
pub fn new(service: String, token_hash: String, encrypted_token: String) -> Self {
|
|
||||||
Credential {
|
|
||||||
id: Uuid::now_v7().to_string(),
|
|
||||||
service,
|
|
||||||
token_hash,
|
|
||||||
encrypted_token,
|
|
||||||
created_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
||||||
expires_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct IntegrationConfig {
|
|
||||||
pub id: String,
|
|
||||||
pub service: String,
|
|
||||||
pub base_url: String,
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub project_name: Option<String>,
|
|
||||||
pub space_key: Option<String>,
|
|
||||||
pub auto_create_enabled: bool,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntegrationConfig {
|
|
||||||
pub fn new(service: String, base_url: String) -> Self {
|
|
||||||
IntegrationConfig {
|
|
||||||
id: Uuid::now_v7().to_string(),
|
|
||||||
service,
|
|
||||||
base_url,
|
|
||||||
username: None,
|
|
||||||
project_name: None,
|
|
||||||
space_key: None,
|
|
||||||
auto_create_enabled: false,
|
|
||||||
updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -162,7 +162,7 @@ export default function NewIssue() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user