Add authentication, user management, and database migration features

- Implement OAuth 2.0 and PAT authentication methods
- Add user management, roles, and profile functionality
- Add database migrations and admin user scripts
- Update services for authentication and user settings
- Add protected routes and permission hooks
- Update documentation for authentication and database access
This commit is contained in:
2026-01-15 03:20:50 +01:00
parent f3637b85e1
commit 1fa424efb9
70 changed files with 15597 additions and 2098 deletions

View File

@@ -49,7 +49,8 @@ class JiraAssetsClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
private isDataCenter: boolean | null = null;
private requestToken: string | null = null;
private serviceAccountToken: string | null = null; // Service account token from .env (for read operations)
private requestToken: string | null = null; // User PAT from profile settings (for write operations)
constructor() {
this.baseUrl = `${config.jiraHost}/rest/insight/1.0`;
@@ -58,17 +59,18 @@ class JiraAssetsClient {
'Accept': 'application/json',
};
// Add PAT authentication if configured
if (config.jiraAuthMethod === 'pat' && config.jiraPat) {
this.defaultHeaders['Authorization'] = `Bearer ${config.jiraPat}`;
}
// Initialize service account token from config (for read operations)
this.serviceAccountToken = config.jiraServiceAccountToken || null;
// User PAT is configured per-user in profile settings
// Authorization header is set per-request via setRequestToken()
}
// ==========================================================================
// Request Token Management (for user-context requests)
// ==========================================================================
setRequestToken(token: string): void {
setRequestToken(token: string | null): void {
this.requestToken = token;
}
@@ -76,6 +78,21 @@ class JiraAssetsClient {
this.requestToken = null;
}
/**
* Check if a token is configured for read operations
* Uses service account token (primary) or user PAT (fallback)
*/
hasToken(): boolean {
return !!(this.serviceAccountToken || this.requestToken);
}
/**
* Check if user PAT is configured for write operations
*/
hasUserToken(): boolean {
return !!this.requestToken;
}
// ==========================================================================
// API Detection
// ==========================================================================
@@ -95,12 +112,26 @@ class JiraAssetsClient {
}
}
private getHeaders(): Record<string, string> {
/**
* Get headers for API requests
* @param forWrite - If true, requires user PAT. If false, uses service account token (or user PAT as fallback)
*/
private getHeaders(forWrite: boolean = false): Record<string, string> {
const headers = { ...this.defaultHeaders };
// Use request-scoped token if available (for user context)
if (this.requestToken) {
if (forWrite) {
// Write operations require user PAT
if (!this.requestToken) {
throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.');
}
headers['Authorization'] = `Bearer ${this.requestToken}`;
} else {
// Read operations: use service account token (primary) or user PAT (fallback)
const token = this.serviceAccountToken || this.requestToken;
if (!token) {
throw new Error('Jira token not configured. Please configure JIRA_SERVICE_ACCOUNT_TOKEN in .env or a Personal Access Token in your user settings.');
}
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
@@ -110,15 +141,21 @@ class JiraAssetsClient {
// Core API Methods
// ==========================================================================
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
/**
* Make a request to Jira API
* @param endpoint - API endpoint
* @param options - Request options
* @param forWrite - If true, requires user PAT for write operations
*/
private async request<T>(endpoint: string, options: RequestInit = {}, forWrite: boolean = false): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url}`);
logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url} (forWrite: ${forWrite})`);
const response = await fetch(url, {
...options,
headers: {
...this.getHeaders(),
...this.getHeaders(forWrite),
...options.headers,
},
});
@@ -136,10 +173,16 @@ class JiraAssetsClient {
// ==========================================================================
async testConnection(): Promise<boolean> {
// Don't test connection if no token is configured
if (!this.hasToken()) {
logger.debug('JiraAssetsClient: No token configured, skipping connection test');
return false;
}
try {
await this.detectApiType();
const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, {
headers: this.getHeaders(),
headers: this.getHeaders(false), // Read operation - uses service account token
});
return response.ok;
} catch (error) {
@@ -150,7 +193,9 @@ class JiraAssetsClient {
async getObject(objectId: string): Promise<JiraAssetsObject | null> {
try {
return await this.request<JiraAssetsObject>(`/object/${objectId}`);
// Include attributes and deep attributes to get full details of referenced objects (including descriptions)
const url = `/object/${objectId}?includeAttributes=true&includeAttributesDeep=1`;
return await this.request<JiraAssetsObject>(url, {}, false); // Read operation
} catch (error) {
// Check if this is a 404 (object not found / deleted)
if (error instanceof Error && error.message.includes('404')) {
@@ -182,7 +227,7 @@ class JiraAssetsClient {
includeAttributesDeep: '1',
objectSchemaId: config.jiraSchemaId,
});
response = await this.request<JiraAssetsSearchResponse>(`/aql/objects?${params.toString()}`);
response = await this.request<JiraAssetsSearchResponse>(`/aql/objects?${params.toString()}`, {}, false); // Read operation
} catch (error) {
// Fallback to deprecated IQL endpoint
logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`);
@@ -194,7 +239,7 @@ class JiraAssetsClient {
includeAttributesDeep: '1',
objectSchemaId: config.jiraSchemaId,
});
response = await this.request<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`);
response = await this.request<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`, {}, false); // Read operation
}
} else {
// Jira Cloud uses POST for AQL
@@ -205,8 +250,9 @@ class JiraAssetsClient {
page,
resultPerPage: pageSize,
includeAttributes: true,
includeAttributesDeep: 1, // Include attributes of referenced objects (e.g., descriptions)
}),
});
}, false); // Read operation
}
const totalCount = response.totalFilterCount || response.totalCount || 0;
@@ -287,6 +333,11 @@ class JiraAssetsClient {
}
async updateObject(objectId: string, payload: JiraUpdatePayload): Promise<boolean> {
// Write operations require user PAT
if (!this.hasUserToken()) {
throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.');
}
try {
logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, {
attributeCount: payload.attributes.length,
@@ -296,7 +347,7 @@ class JiraAssetsClient {
await this.request(`/object/${objectId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}, true); // Write operation - requires user PAT
logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`);
return true;
@@ -337,7 +388,36 @@ class JiraAssetsClient {
// Parse each attribute based on schema
for (const attrDef of typeDef.attributes) {
const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name);
result[attrDef.fieldName] = this.parseAttributeValue(jiraAttr, attrDef);
const parsedValue = this.parseAttributeValue(jiraAttr, attrDef);
result[attrDef.fieldName] = parsedValue;
// Debug logging for Confluence Space field
if (attrDef.fieldName === 'confluenceSpace') {
logger.info(`[Confluence Space Debug] Object ${jiraObj.objectKey || jiraObj.id}:`);
logger.info(` - Attribute definition: name="${attrDef.name}", jiraId=${attrDef.jiraId}, type="${attrDef.type}"`);
logger.info(` - Found attribute: ${jiraAttr ? 'yes' : 'no'}`);
if (!jiraAttr) {
// Log all available attributes to help debug
const availableAttrs = jiraObj.attributes?.map(a => {
const attrName = a.objectTypeAttribute?.name || 'unnamed';
return `${attrName} (ID: ${a.objectTypeAttributeId})`;
}).join(', ') || 'none';
logger.warn(` - Available attributes (${jiraObj.attributes?.length || 0}): ${availableAttrs}`);
// Try to find similar attributes
const similarAttrs = jiraObj.attributes?.filter(a => {
const attrName = a.objectTypeAttribute?.name || '';
const lowerAttrName = attrName.toLowerCase();
return lowerAttrName.includes('confluence') || lowerAttrName.includes('space');
});
if (similarAttrs && similarAttrs.length > 0) {
logger.warn(` - Found similar attributes: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
}
} else {
logger.info(` - Raw attribute: ${JSON.stringify(jiraAttr, null, 2)}`);
logger.info(` - Parsed value: ${parsedValue} (type: ${typeof parsedValue})`);
}
}
}
return result as T;
@@ -363,7 +443,7 @@ class JiraAssetsClient {
private parseAttributeValue(
jiraAttr: JiraAssetsAttribute | undefined,
attrDef: { type: string; isMultiple: boolean }
attrDef: { type: string; isMultiple: boolean; fieldName?: string }
): unknown {
if (!jiraAttr?.objectAttributeValues?.length) {
return attrDef.isMultiple ? [] : null;
@@ -371,6 +451,30 @@ class JiraAssetsClient {
const values = jiraAttr.objectAttributeValues;
// Generic Confluence field detection: check if any value has a confluencePage
// This works for all Confluence fields regardless of their declared type (float, text, etc.)
const hasConfluencePage = values.some(v => v.confluencePage);
if (hasConfluencePage) {
const confluencePage = values[0]?.confluencePage;
if (confluencePage?.url) {
logger.info(`[Confluence Field Parse] Found Confluence URL for field "${attrDef.fieldName || 'unknown'}": ${confluencePage.url}`);
// For multiple values, return array of URLs; for single, return the URL string
if (attrDef.isMultiple) {
return values
.filter(v => v.confluencePage?.url)
.map(v => v.confluencePage!.url);
}
return confluencePage.url;
}
// Fallback to displayValue if no URL
const displayVal = values[0]?.displayValue;
if (displayVal) {
logger.info(`[Confluence Field Parse] Using displayValue as fallback for field "${attrDef.fieldName || 'unknown'}": ${displayVal}`);
return String(displayVal);
}
return null;
}
switch (attrDef.type) {
case 'reference': {
const refs = values
@@ -403,8 +507,19 @@ class JiraAssetsClient {
}
case 'float': {
// Regular float parsing
const val = values[0]?.value;
return val ? parseFloat(val) : null;
const displayVal = values[0]?.displayValue;
// Try displayValue first, then value
if (displayVal !== undefined && displayVal !== null) {
const parsed = typeof displayVal === 'string' ? parseFloat(displayVal) : Number(displayVal);
return isNaN(parsed) ? null : parsed;
}
if (val !== undefined && val !== null) {
const parsed = typeof val === 'string' ? parseFloat(val) : Number(val);
return isNaN(parsed) ? null : parsed;
}
return null;
}
case 'boolean': {