Compare commits

...

2 Commits

Author SHA1 Message Date
Shaun Arman
fd244781e1 feat: add database schema for integration credentials and config
Some checks failed
Test / frontend-typecheck (push) Waiting to run
Test / frontend-tests (push) Waiting to run
Auto Tag / auto-tag (push) Successful in 6s
Release / build-macos-arm64 (push) Failing after 1m32s
Test / rust-fmt-check (push) Successful in 2m6s
Test / rust-clippy (push) Successful in 17m38s
Release / build-linux-arm64 (push) Failing after 20m53s
Test / rust-tests (push) Has been cancelled
Release / build-linux-amd64 (push) Failing after 13m24s
Release / build-windows-amd64 (push) Failing after 7m37s
Phase 2.1: Database schema + credentials storage

Added migration 011 with:
- credentials table: Encrypted OAuth tokens per service
- integration_config table: Base URLs, project names, space keys

Added models:
- Credential: Stores token hash and encrypted token
- IntegrationConfig: Stores service configuration

TDD tests (7 passing):
- Table creation verification
- Column structure validation
- Insert/retrieve operations
- Service uniqueness constraints
- Migration tracking
- Idempotency checks

All tests pass. Schema ready for OAuth2 implementation.
2026-04-03 14:23:49 -05:00
Shaun Arman
bbc43f7428 fix: improve Cancel button contrast in AI disclaimer modal
Changed variant from 'outline' to 'secondary' for better visibility
in dark theme. The outline variant had insufficient contrast making
the button difficult to read.
2026-04-03 14:20:44 -05:00
4 changed files with 465 additions and 1 deletions

View File

@ -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.)

View File

@ -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<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);
}
}

View File

@ -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<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(),
}
}
}

View File

@ -162,7 +162,7 @@ export default function NewIssue() {
</Button>
<Button
onClick={() => navigate("/")}
variant="outline"
variant="secondary"
className="flex-1"
>
Cancel