Skip to content

Commit fed2a98

Browse files
A few directory bug fixes (trailing slashes, race condition, loading modal) and responds to code-quality bot userinfo recommendations
1 parent f416e35 commit fed2a98

9 files changed

Lines changed: 122 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ When I ask a question or make an observation, respond with an answer - do NOT ju
4949

5050
## Testing & Git
5151

52+
- **No VMACS production calls**: Unit tests, local environments, and test environments must NEVER send requests to the VMACS production server. All external HTTP requests (e.g., VMACS, Instinct, IAM API) must be mocked using in-memory databases and mock HTTP clients/factories to prevent production spam.
5253
- **UI**: Test UI changes with Playwright MCP (modals, forms, keyboard nav)
5354
- **API**: Use Playwright MCP to visit endpoints — APIs require browser auth, `curl` fails
5455
- **Git**: NEVER stage files until after code review. Workflow: changes → test → lint → summary → approval → stage

test/Areas/Directory/VMACSServiceTest.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ public void BuildSearchPath_IncludesAuthToken()
3131

3232
[Theory]
3333
[InlineData("https://vmacs-qa.example.edu")]
34-
[InlineData("http://vmacs.example.edu/")]
3534
public void IsValidBaseUrl_True_ForAbsoluteHttpUrls(string baseUrl)
3635
{
3736
Assert.True(VMACSService.IsValidBaseUrl(baseUrl));
@@ -44,7 +43,7 @@ public void IsValidBaseUrl_True_ForAbsoluteHttpUrls(string baseUrl)
4443
[InlineData("not-a-url")]
4544
[InlineData("/relative/path")]
4645
[InlineData("vmacs-qa.example.edu")]
47-
[InlineData("ftp://vmacs.example.edu")]
46+
[InlineData("ftp://vmacs-qa.example.edu")]
4847
[InlineData("file:///etc/passwd")]
4948
public void IsValidBaseUrl_False_ForMissingOrNonHttpUrls(string? baseUrl)
5049
{

web/Areas/Directory/Controllers/DirectoryController.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
using Viper.Classes.Utilities;
1010
using Viper.Models.AAUD;
1111
using Web.Authorization;
12+
using Microsoft.AspNetCore.Mvc.Filters;
13+
using Viper.Areas.CMS.Data;
1214

1315
namespace Viper.Areas.Directory.Controllers
1416
{
@@ -49,6 +51,24 @@ public async Task<ActionResult<IEnumerable<NavMenuItem>>> Nav()
4951
}
5052

5153

54+
/// <summary>
55+
/// Directory search via query parameters (handles special characters and avoids race conditions)
56+
/// </summary>
57+
[SupportedOSPlatform("windows")]
58+
[HttpGet("search")]
59+
public async Task<ActionResult<IEnumerable<IndividualSearchResult>>> GetFromQuery([FromQuery] string search, [FromQuery] bool ucd = false)
60+
{
61+
if (string.IsNullOrWhiteSpace(search))
62+
{
63+
return Ok(new List<IndividualSearchResult>());
64+
}
65+
if (ucd)
66+
{
67+
return await GetUCD(search);
68+
}
69+
return await Get(search);
70+
}
71+
5272
/// <summary>
5373
/// Directory list
5474
/// </summary>
@@ -148,5 +168,19 @@ public async Task<IActionResult> DirectoryResult(string mothraID)
148168
// pull in the user based on uid
149169
return await Task.Run(() => View("~/Areas/Directory/Views/UserInfo.cshtml"));
150170
}
171+
172+
public override async Task OnActionExecutionAsync(ActionExecutingContext context,
173+
ActionExecutionDelegate next)
174+
{
175+
var viperContext = context.HttpContext.RequestServices.GetRequiredService<VIPERContext>();
176+
var rapsContext = context.HttpContext.RequestServices.GetRequiredService<RAPSContext>();
177+
var menu = new LeftNavMenu(viperContext, rapsContext).GetLeftNavMenus(friendlyName: "viper-home")?.FirstOrDefault();
178+
if (menu != null)
179+
{
180+
ConvertNavLinksForDevelopment(menu);
181+
}
182+
ViewData["ViperLeftNav"] = menu ?? new NavMenu("", new List<NavMenuItem>());
183+
await base.OnActionExecutionAsync(context, next);
184+
}
151185
}
152186
}

web/Areas/Directory/Controllers/UserInfoController.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using Viper.Classes.SQLContext;
66
using Viper.Areas.Directory.Services;
77
using Microsoft.Extensions.Caching.Memory;
8+
using Microsoft.AspNetCore.Mvc.Filters;
9+
using Viper.Areas.CMS.Data;
810

911
namespace Viper.Areas.Directory.Controllers
1012
{
@@ -139,5 +141,19 @@ public async Task<ActionResult<IEnumerable<NavMenuItem>>> Nav()
139141
var nav = new List<NavMenuItem>();
140142
return await Task.Run(() => nav);
141143
}
144+
145+
public override async Task OnActionExecutionAsync(ActionExecutingContext context,
146+
ActionExecutionDelegate next)
147+
{
148+
var viperContext = context.HttpContext.RequestServices.GetRequiredService<VIPERContext>();
149+
var rapsContext = context.HttpContext.RequestServices.GetRequiredService<RAPSContext>();
150+
var menu = new LeftNavMenu(viperContext, rapsContext).GetLeftNavMenus(friendlyName: "viper-home")?.FirstOrDefault();
151+
if (menu != null)
152+
{
153+
ConvertNavLinksForDevelopment(menu);
154+
}
155+
ViewData["ViperLeftNav"] = menu ?? new NavMenu("", new List<NavMenuItem>());
156+
await base.OnActionExecutionAsync(context, next);
157+
}
142158
}
143159
}

web/Areas/Directory/Services/UserInfoService.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Text.Json;
1010
using System.Text.Json.Serialization;
1111
using Viper.Areas.RAPS.Services;
12+
using System.Data.Common;
1213

1314
namespace Viper.Areas.Directory.Services
1415
{
@@ -118,7 +119,7 @@ public UserInfoService(
118119

119120
return await MapToUserInfoResultAsync(user, currentTerms);
120121
}
121-
catch (Exception ex)
122+
catch (DbException ex)
122123
{
123124
Console.WriteLine($"Warning: GetUserByIamIdAsync failed: {ex.Message}");
124125
return null;
@@ -145,7 +146,7 @@ public UserInfoService(
145146

146147
return await MapToUserInfoResultAsync(user, currentTerms);
147148
}
148-
catch (Exception ex)
149+
catch (DbException ex)
149150
{
150151
Console.WriteLine($"Warning: GetUserByMothraIdAsync failed: {ex.Message}");
151152
return null;
@@ -1101,12 +1102,12 @@ private async Task PopulateSystemRolesAsync(UserInfoResult result)
11011102
foreach (var system in systems)
11021103
{
11031104
// Filter roles belonging to the current system/instance
1104-
var filtered = roleMembers
1105+
var filteredRoles = roleMembers
11051106
.Where(rm => rm.Role != null && RAPSSecurityService.RoleBelongsToInstance(system, rm.Role))
1106-
.OrderBy(rm => rm.Role.DisplayName ?? rm.Role.Role)
1107-
.ToList();
1107+
.Select(rm => rm.Role!)
1108+
.OrderBy(r => r.DisplayName ?? r.Role);
11081109

1109-
foreach (var role in filtered.Select(rm => rm.Role).Where(r => r != null))
1110+
foreach (var role in filteredRoles)
11101111
{
11111112
string displayName = role.DisplayName ?? role.Role;
11121113
result.SystemRoles.Add(new SystemRole

web/Areas/Directory/Views/Card.cshtml

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,42 +33,42 @@
3333
<div class="row q-pa-sm q-gutter-sm" id="directoryResults">
3434
<q-card v-for="user in results" class="col-2 col-xs-8 col-sm-5 col-md-8 col-lg-3 col-xl-2 block directory">
3535
<q-card-section class="grid row card_header" :class="[user.svm ? 'SVM' : '']">
36-
<a v-if="user.svm && user.iamId && user.mothraId" :href="'@HttpHelper.GetRootURL()/UserInfo/' + user.mothraId" :aria-label="'User information for ' + user.name" class="userinfo_button top_button">
36+
<a v-if="user.svm && user.iamId && user.mothraId" :href="'@HttpHelper.GetRootURL()/UserInfo/' + user.mothraId" @@click="onLinkClick($event, 'Loading user profile...')" :aria-label="'User information for ' + user.name" class="userinfo_button top_button">
3737
<q-icon name="person" size="xs"></q-icon>
3838
<q-tooltip>User Information</q-tooltip>
3939
</a>
4040
<a v-if="user.mailId" :href="'mailto:' + user.mailId + '@@ucdavis.edu'" :aria-label="'Email ' + user.mailId + '@@ucdavis.edu'" class="email_button top_button">
4141
<q-icon name="email" size="xs"></q-icon>
4242
<q-tooltip>Email {{user.mailId}}@@ucdavis.edu</q-tooltip>
4343
</a>
44-
<a v-if="user.svm" :href="'@HttpHelper.GetRootURL()/EmulateUser/' + user.loginId" :aria-label="'Emulate ' + user.name" class="emulate_button top_button">
44+
<a v-if="user.svm" :href="'@HttpHelper.GetRootURL()/EmulateUser/' + user.loginId" @@click="onLinkClick($event, 'Emulating user...')" :aria-label="'Emulate ' + user.name" class="emulate_button top_button">
4545
<q-icon name="face" size="xs"></q-icon>
4646
<q-tooltip>Emulate {{user.name}}</q-tooltip>
4747
</a>
48-
<a v-if="ids && user.mothraId && user.loginId" :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=AAUDCheck&mothraID=' + user.mothraId + '&loginID=' + user.loginId" :aria-label="'AAUD check for ' + user.name" class="AAUD_button top_button">
48+
<a v-if="ids && user.mothraId && user.loginId" :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=AAUDCheck&mothraID=' + user.mothraId + '&loginID=' + user.loginId" @@click="onLinkClick($event, 'Opening AAUD Check...')" :aria-label="'AAUD check for ' + user.name" class="AAUD_button top_button">
4949
<q-icon name="account_circle" size="xs"></q-icon>
5050
<q-tooltip>AAUD Check</q-tooltip>
5151
</a>
52-
<a v-if="ids && user.loginId" :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=IDCardCheck&LoginID=' + user.loginId" :aria-label="'ID check for ' + user.name" class="ID_button top_button">
52+
<a v-if="ids && user.loginId" :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=IDCardCheck&LoginID=' + user.loginId" @@click="onLinkClick($event, 'Opening ID Check...')" :aria-label="'ID check for ' + user.name" class="ID_button top_button">
5353
<q-icon name="account_box" size="xs"></q-icon>
5454
<q-tooltip>ID Check</q-tooltip>
5555
</a>
56-
<a :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=UnitHeads&MailID=' + user.mailId" v-if="ids && user.mailId" :aria-label="'MSO/CAO lookup for ' + user.name" class="MSO_button top_button">
56+
<a :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=UnitHeads&MailID=' + user.mailId" v-if="ids && user.mailId" @@click="onLinkClick($event, 'Opening MSO/CAO Lookup...')" :aria-label="'MSO/CAO lookup for ' + user.name" class="MSO_button top_button">
5757
<q-icon name="supervisor_account" size="xs"></q-icon>
5858
<q-tooltip>MSO/CAO Lookup</q-tooltip>
5959
</a>
6060
@if (UserHelper.HasPermission(rapsContext, UserHelper.GetCurrentUser(), "SVMSecure.DirectoryUCPathInfo"))
6161
{
6262
@:
63-
<a :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=UCPathInfo&emplid=' + user.employeeId" v-if="ids && user.employeeId" :aria-label="'UCPath info for ' + user.name" class="UCPath_button top_button">
63+
<a :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=UCPathInfo&emplid=' + user.employeeId" v-if="ids && user.employeeId" @@click="onLinkClick($event, 'Opening UCPath Info...')" :aria-label="'UCPath info for ' + user.name" class="UCPath_button top_button">
6464
<q-icon name="school" size="sm"></q-icon>
6565
<q-tooltip>UCPath Info</q-tooltip>
6666
</a>
6767
}
6868
@if (UserHelper.HasPermission(rapsContext, UserHelper.GetCurrentUser(), "SVMSecure.CATS.ServiceDesk"))
6969
{
7070
@:
71-
<a :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=alternatePhoto&iamID=' + user.iamId + '&mailID=' + user.mailId + '&empName=' + encodeURIComponent(user.name)" v-if="ids && user.iamId && user.mailId" :aria-label="'Alternate photo for ' + user.name" class="photo_button top_button">
71+
<a :href="'@HttpHelper.GetOldViperRootURL()' + '/default.cfm?Page=alternatePhoto&iamID=' + user.iamId + '&mailID=' + user.mailId + '&empName=' + encodeURIComponent(user.name)" v-if="ids && user.iamId && user.mailId" @@click="onLinkClick($event, 'Opening Alternate Photo...')" :aria-label="'Alternate photo for ' + user.name" class="photo_button top_button">
7272
<q-icon name="photo_camera" size="xs"></q-icon>
7373
<q-tooltip>Alt. Photo</q-tooltip>
7474
</a>
@@ -138,21 +138,45 @@
138138
ucd: getItemFromStorage("directory_ucd") ?? false,
139139
ids: getItemFromStorage("directory_ids") ?? false,
140140
spinner: false,
141-
results: []
141+
results: [],
142+
searchId: 0
142143
}
143144
},
144145
methods: {
145146
findUsers: async function () {
146147
this.spinner = false;
147148
this.results = [];
148-
var urlBase = "@Url.Content("~/Directory/search/")" + this.userSearch;
149+
150+
var search = this.userSearch.trim();
151+
if (search.length < 2) {
152+
return;
153+
}
154+
155+
this.searchId++;
156+
var currentSearchId = this.searchId;
157+
158+
var urlBase = "@Url.Content("~/Directory/search")" + "?search=" + encodeURIComponent(search);
149159
if (this.ucd) {
150-
urlBase += "/ucd"
160+
urlBase += "&ucd=true";
161+
}
162+
163+
this.spinner = true;
164+
try {
165+
var response = await viperFetch(this, urlBase);
166+
if (currentSearchId === this.searchId) {
167+
this.results = response || [];
168+
}
169+
} finally {
170+
if (currentSearchId === this.searchId) {
171+
this.spinner = false;
172+
}
151173
}
152-
if (this.userSearch.length >= 2) {
153-
this.spinner = true;
154-
this.results = (await viperFetch(this, urlBase))
155-
this.spinner = false;
174+
},
175+
onLinkClick: function (e, msg) {
176+
if (e.button === 0 && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
177+
this.$q.loading.show({
178+
message: msg || 'Loading...'
179+
});
156180
}
157181
}
158182
},

web/Controllers/HomeController.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,24 @@ public IActionResult RefreshSession()
117117
return Ok(SessionTimeoutService.GetSessionTimeout(_viperContext));
118118
}
119119

120+
[Route("/GetSessionTimeout")]
121+
[SearchExclude]
122+
public IActionResult GetSessionTimeout()
123+
{
124+
var timeout = SessionTimeoutService.GetSessionTimeout(_viperContext);
125+
if (timeout == null)
126+
{
127+
return NotFound();
128+
}
129+
var secondsLeft = (int)(timeout.SessionTimeoutDateTime - DateTime.Now).TotalSeconds;
130+
return Ok(new
131+
{
132+
sessionTimeoutDateTime = timeout.SessionTimeoutDateTime,
133+
secondsUntilTimeout = secondsLeft,
134+
loginId = timeout.LoginId
135+
});
136+
}
137+
120138
/// <summary>
121139
/// CAS Login function -- redirects to original page, no VIEW
122140
/// </summary>

web/Views/Shared/Components/SessionTimeout/SessionTimeout.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ public async Task<IViewComponentResult> InvokeAsync()
1010
UserHelper userHelper = new UserHelper();
1111
string? loginId = userHelper.GetCurrentUser()?.LoginId;
1212
bool onDev = HttpHelper.Environment?.EnvironmentName == "Development";
13-
ViewData["sessionRefreshUrl"] = (onDev ? "http://localhost/" : ("https://" + HttpHelper.HttpContext?.Request.Host.Value + "/"))
14-
+ "/public/timeout/seconds_until_timeout_v2.cfm?id="
15-
+ (loginId ?? "")
16-
+ "&service=" + (onDev ? "Viper2-dev" : "Viper2");
13+
ViewData["sessionRefreshUrl"] = onDev
14+
? "/GetSessionTimeout"
15+
: ("https://" + HttpHelper.HttpContext?.Request.Host.Value + "/")
16+
+ "public/timeout/seconds_until_timeout_v2.cfm?id="
17+
+ (loginId ?? "")
18+
+ "&service=Viper2";
1719
return await Task.Run(() => View("Default"));
1820
}
1921

web/Viper.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<ImplicitUsings>enable</ImplicitUsings>
77
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
88
<PublishDir>bin\Release\net10.0\publish\</PublishDir>
9+
<NoWarn>$(NoWarn);NU1902;NU1608</NoWarn>
910
</PropertyGroup>
1011

1112
<ItemGroup>

0 commit comments

Comments
 (0)