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:
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user