diff --git a/TICKET_SUMMARY_AI_DISCLAIMER.md b/TICKET_SUMMARY_AI_DISCLAIMER.md new file mode 100644 index 00000000..f451083e --- /dev/null +++ b/TICKET_SUMMARY_AI_DISCLAIMER.md @@ -0,0 +1,178 @@ +# 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.) diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index e3e4328b..e860aaef 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -127,6 +127,29 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { 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 { @@ -151,3 +174,214 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { 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 = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .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 = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .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); + } +} diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index c3b30cde..6f06b850 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -340,3 +340,55 @@ pub struct SettingRecord { pub value: 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, +} + +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, + pub project_name: Option, + pub space_key: Option, + 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(), + } + } +}