diff --git a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs index 676de0ac..f015e1bf 100644 --- a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs +++ b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs @@ -173,6 +173,14 @@ public class KeywordIntentClassifier : INLUProvider "list_messages", null), (R(@"^(show|list|get|what'?s)\s+(on\s+)?(the\s+)?(calendar|schedule|agenda)"), "list_calendar", null), + // Upcoming-calendar phrasings: "when is the next event?", "what is upcoming in the + // calendar?", "upcoming events", "what's coming up", "next events". + (R(@"^when('?s|\s+is)\s+(the\s+)?next\s+(event|meeting|training|class)(s|es)?$"), + "list_calendar", null), + (R(@"^(what('?s|\s+is)\s+)?(upcoming|coming\s+up)(\s+(events?|meetings?|trainings?))?(\s+(on|in)\s+(the\s+)?(calendar|schedule|agenda))?$"), + "list_calendar", null), + (R(@"^(next|upcoming)\s+(events?|meetings?|trainings?)$"), + "list_calendar", null), (R(@"^(show|list|get|my)\s+shifts?"), "list_shifts", null), (R(@"^(weather\s+)?(alerts?|warnings?)"), diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs index 90a49b9d..1e9f3a68 100644 --- a/Core/Resgrid.Services/ChatMessageService.cs +++ b/Core/Resgrid.Services/ChatMessageService.cs @@ -343,6 +343,19 @@ public async Task> GetThreadPageAsync(string threadRootMessage if (member != null && (member.IsBanned || (member.MutedUntil.HasValue && member.MutedUntil.Value > DateTime.UtcNow))) return false; + // Double-taps are common: bail out before the insert so the ordinary duplicate never + // reaches the database (RepositoryBase logs every insert exception, so relying on the + // unique-violation catch below alone floods the error log). The catch still covers the + // genuine concurrent race two requests can win simultaneously. + var existingReactions = await _chatMessageReactionRepository.GetByMessageIdsAsync(new[] { chatMessageId }); + var alreadyReacted = existingReactions != null && existingReactions.Any(r => + string.Equals(r.Emoji, emoji, StringComparison.Ordinal) + && (unitId.HasValue + ? r.UnitId == unitId + : r.UserId != null && string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase))); + if (alreadyReacted) + return true; + try { await _chatMessageReactionRepository.InsertAsync(new ChatMessageReaction diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0113_FixNotesDocumentsCategoryColumn.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0113_FixNotesDocumentsCategoryColumn.cs new file mode 100644 index 00000000..5ef4f0a4 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0113_FixNotesDocumentsCategoryColumn.cs @@ -0,0 +1,31 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// The initial schema created Notes and Documents with a misspelled "Catery" column while the + /// entities (and the Dapper-generated INSERT/UPDATE statements) use "Category" β€” every save + /// against a database built from M0001 failed with "invalid column name 'Category'". Renames + /// the column where the typo exists; guarded so databases that already have the correct + /// column (or were hand-fixed) are untouched. + /// + [Migration(113)] + public class M0113_FixNotesDocumentsCategoryColumn : Migration + { + public override void Up() + { + Execute.Sql(@" +IF COL_LENGTH('dbo.Notes', 'Catery') IS NOT NULL AND COL_LENGTH('dbo.Notes', 'Category') IS NULL + EXEC sp_rename 'dbo.Notes.Catery', 'Category', 'COLUMN';"); + + Execute.Sql(@" +IF COL_LENGTH('dbo.Documents', 'Catery') IS NOT NULL AND COL_LENGTH('dbo.Documents', 'Category') IS NULL + EXEC sp_rename 'dbo.Documents.Catery', 'Category', 'COLUMN';"); + } + + public override void Down() + { + // One-way typo fix; nothing to restore. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Sql/M0001_InitialMigration.sql b/Providers/Resgrid.Providers.Migrations/Sql/M0001_InitialMigration.sql index fdaa887c..25c4f74f 100644 Binary files a/Providers/Resgrid.Providers.Migrations/Sql/M0001_InitialMigration.sql and b/Providers/Resgrid.Providers.Migrations/Sql/M0001_InitialMigration.sql differ diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0113_FixNotesDocumentsCategoryColumnPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0113_FixNotesDocumentsCategoryColumnPg.cs new file mode 100644 index 00000000..7c6701a1 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0113_FixNotesDocumentsCategoryColumnPg.cs @@ -0,0 +1,37 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// The initial schema created notes and documents with a misspelled "catery" column while the + /// entities (and the Dapper-generated INSERT/UPDATE statements) use "category" β€” every save + /// against a database built from M0001 failed with 42703 "column category does not exist". + /// Renames the column where the typo exists; guarded so databases that already have the + /// correct column (or were hand-fixed) are untouched. + /// + [Migration(113)] + public class M0113_FixNotesDocumentsCategoryColumnPg : Migration + { + public override void Up() + { + Execute.Sql(@" +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'notes' AND column_name = 'catery') + AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'notes' AND column_name = 'category') THEN + ALTER TABLE public.notes RENAME COLUMN catery TO category; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'documents' AND column_name = 'catery') + AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'documents' AND column_name = 'category') THEN + ALTER TABLE public.documents RENAME COLUMN catery TO category; + END IF; +END $$;"); + } + + public override void Down() + { + // One-way typo fix; nothing to restore. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Sql/M0001_InitialMigration.sql b/Providers/Resgrid.Providers.MigrationsPg/Sql/M0001_InitialMigration.sql index 2786a048..d1c04dfe 100644 Binary files a/Providers/Resgrid.Providers.MigrationsPg/Sql/M0001_InitialMigration.sql and b/Providers/Resgrid.Providers.MigrationsPg/Sql/M0001_InitialMigration.sql differ diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs index b09a49f1..7b81617e 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs @@ -36,6 +36,13 @@ public void Setup() [TestCase("Whats my schedule?", "my_schedule")] [TestCase("my unread messages?", "list_messages")] [TestCase("new messages", "list_messages")] + [TestCase("When is the next event?", "list_calendar")] + [TestCase("when's the next meeting", "list_calendar")] + [TestCase("What is upcoming in the calendar?", "list_calendar")] + [TestCase("what's coming up", "list_calendar")] + [TestCase("upcoming events", "list_calendar")] + [TestCase("next events", "list_calendar")] + [TestCase("Whats my schedule", "my_schedule")] public async Task Classifies_intent(string text, string expectedIntent) { var result = await _classifier.ClassifyAsync(text); diff --git a/Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs index 079c92c1..f66fd22e 100644 --- a/Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs @@ -74,5 +74,66 @@ public async Task DeleteMessageAsync_should_use_one_effective_actor_classificati payload.Value("DeletedByModerator").Should().Be(expectedModerated); payload.Value("IsModerated").Should().Be(expectedModerated); } + + // Double-tapping a reaction fires two AddReaction calls; the second must no-op without + // attempting the insert (the unique-index violation would flood the error log). + [TestCase("πŸ™", true, false)] // same emoji already present -> success, no insert + [TestCase("πŸ”₯", true, true)] // different emoji -> insert proceeds + public async Task AddReactionAsync_is_idempotent_for_duplicate_reactions(string emoji, bool expectedResult, bool expectInsert) + { + var message = new ChatMessage + { + ChatMessageId = "message-1", + ChatChannelId = "channel-1", + DepartmentId = 1, + SenderUserId = "sender", + Body = "body" + }; + var channel = new ChatChannel { ChatChannelId = message.ChatChannelId, DepartmentId = message.DepartmentId }; + var channelRepository = new Mock(); + var messageRepository = new Mock(); + var reactionRepository = new Mock(); + + messageRepository.Setup(x => x.GetByIdAsync(message.ChatMessageId)).ReturnsAsync(message); + channelRepository.Setup(x => x.GetByIdAsync(channel.ChatChannelId)).ReturnsAsync(channel); + reactionRepository + .Setup(x => x.GetByMessageIdsAsync(It.IsAny>())) + .ReturnsAsync(new[] + { + new ChatMessageReaction + { + ChatMessageId = message.ChatMessageId, + ParticipantType = (int)ChatParticipantType.User, + UserId = "USER-1", + Emoji = "πŸ™" + } + }); + reactionRepository + .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), false)) + .ReturnsAsync((ChatMessageReaction reaction, CancellationToken _, bool __) => reaction); + + var service = new ChatMessageService( + channelRepository.Object, + messageRepository.Object, + Mock.Of(), + Mock.Of(), + reactionRepository.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + // Case-insensitive user match: stored UserId is "USER-1", caller sends "user-1". + var result = await service.AddReactionAsync(message.ChatMessageId, "user-1", null, emoji); + + result.Should().Be(expectedResult); + reactionRepository.Verify( + x => x.InsertAsync(It.IsAny(), It.IsAny(), false), + expectInsert ? Times.Once() : Times.Never()); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 2b2684d9..7d2d56e4 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -902,6 +902,9 @@ public async Task> EditMessage(string message if (input == null || String.IsNullOrWhiteSpace(input.Body)) return BadRequest(); + if (await IsChatbotMessageChannelAsync(messageId)) + return BadRequest("Messages can't be edited in assistant conversations."); + var message = await _chatMessageService.EditMessageAsync(messageId, UserId, input.Body, cancellationToken); if (message == null) @@ -1589,7 +1592,7 @@ private async Task IsRateLimitedAsync(string action, int limitPerWindow) /// /// True when the message lives in an assistant (chatbot) conversation, where reactions, - /// threads and deletes are not available. + /// threads, deletes and edits are not available. /// private async Task IsChatbotMessageChannelAsync(string messageId) { diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 73c5b523..1d8a0eda 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -737,7 +737,7 @@ True when the message lives in an assistant (chatbot) conversation, where reactions, - threads and deletes are not available. + threads, deletes and edits are not available. diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx index 8d67e473..eae3a7cc 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import './chat.css'; -import { getCurrentUserId, type ChatChannelDto, type ChatMessageDto } from './types'; +import { ChatChannelType, getCurrentUserId, type ChatChannelDto, type ChatMessageDto } from './types'; import { useChatBootstrap } from './useChatBootstrap'; import { useChatStore, shallowArrayEqual } from './useChatStore'; import { setActiveChannel } from './chatStore'; @@ -21,8 +21,11 @@ export interface ChatPanelElementProps { export default function ChatPanelElement({ hostElement, label = 'Chat' }: ChatPanelElementProps) { const { available, loaded, loadFailed, reload, connect } = useChatBootstrap(); - const channels = useChatStore((state) => state.channels, shallowArrayEqual); - const unread = useChatStore((state) => state.channels.reduce((total, channel) => total + Math.max(0, channel.UnreadCount), 0)); + const allChannels = useChatStore((state) => state.channels, shallowArrayEqual); + // The assistant has its own footer button/drawer (rg-assistant); keep its channel β€” and its + // unread count β€” out of the chat popout entirely. + const channels = allChannels.filter((channel) => channel.ChannelType !== ChatChannelType.Chatbot); + const unread = channels.reduce((total, channel) => total + Math.max(0, channel.UnreadCount), 0); const [open, setOpen] = useState(false); const [activeChannelId, setActiveChannelId] = useState(null); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ConversationView.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ConversationView.tsx index dc273aef..69325307 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ConversationView.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ConversationView.tsx @@ -57,8 +57,8 @@ export default function ConversationView(props: ConversationViewProps) { const channelId = channel.ChatChannelId; // Assistant conversations are restricted regardless of where they're opened (footer drawer uses // variant='bot'; the chat page renders the same channel with the default variant): text only β€” - // no emoji picker, GIFs, images, urgent priority, reactions, threads or deletes. Pin, flag and - // editing your own messages stay available. + // no emoji picker, GIFs, images, urgent priority, reactions, threads, deletes or edits. Pin and + // flag stay available. const isBot = variant === 'bot' || channel.ChannelType === ChatChannelType.Chatbot; const allMessages = useChatStore((state) => state.messagesByChannel[channelId] ?? EMPTY_MESSAGES, shallowArrayEqual); @@ -313,7 +313,7 @@ export default function ConversationView(props: ConversationViewProps) { showAckStatus={message.Priority === 1 && (message.SenderUserId === currentUserId || !!canModerate)} onReact={isBot ? undefined : handleReact} onOpenThread={isBot ? undefined : props.onOpenThread} - onSaveEdit={handleSaveEdit} + onSaveEdit={isBot ? undefined : handleSaveEdit} onDelete={isBot ? undefined : handleDelete} onPin={canModerate ? handlePin : undefined} onFlag={props.onFlag} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx index 796a5809..b44b788e 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx @@ -312,7 +312,16 @@ export default function Composer({ )} - void handleFile(event.target.files?.[0])} /> + {allowImages && ( + void handleFile(event.target.files?.[0])} + /> + )} ); } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/MessageBubble.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/MessageBubble.tsx index df233700..5c8f4d72 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/MessageBubble.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/MessageBubble.tsx @@ -79,7 +79,7 @@ function MessageBubble(props: MessageBubbleProps) { bubbleClasses.push('rgchat-bubble--bot'); } - const canEdit = isMine && message.MessageType === ChatMessageType.Text && !isDeleted && !isFailed; + const canEdit = isMine && message.MessageType === ChatMessageType.Text && !isDeleted && !isFailed && !!props.onSaveEdit; const canDelete = (isMine || canModerate) && !isDeleted && !isFailed; const renderContent = () => { diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css index b20b9d75..10361736 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css @@ -999,10 +999,14 @@ rg-chat { animation: rgchat-pop-in 140ms ease; } -/* Narrow hosts (footer chat popout / assistant drawer): the quick-reactions row would extend - past the panel edge and get clipped, so wrap it into a compact grid instead. */ +/* Narrow hosts (footer chat popout / assistant drawer): anchored to the right edge of the + actions row, the quick-reactions popover extends left past the panel edge and gets clipped. + Anchor it to the LEFT edge instead so it grows rightward over the message area, and wrap it + into a compact grid as a guard for very narrow widths. */ .rgchat-panel .rgchat-popover--reactions, .rgchat-drawer .rgchat-popover--reactions { + left: 0; + right: auto; flex-wrap: wrap; justify-content: center; width: max-content; diff --git a/Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs b/Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs index b4c4d925..618d13d2 100644 --- a/Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs +++ b/Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs @@ -1,14 +1,23 @@ -ο»Ώusing Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; using Resgrid.Config; using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Net; using System.Net.Http; +using System.Threading.Tasks; namespace Resgrid.WebCore.Attributes { public class GoogleReCaptchaValidationAttribute : ValidationAttribute { + // Shared client: a new HttpClient per validation leaks sockets under load ("Resource + // temporarily unavailable" on the register form). Validation attributes are synchronous, + // so the call is bounded by a short timeout instead of the 100-second default. + private static readonly HttpClient _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(10) + }; protected override ValidationResult IsValid(object value, ValidationContext validationContext) { @@ -22,23 +31,37 @@ protected override ValidationResult IsValid(object value, ValidationContext vali String reCaptchResponse = value.ToString(); String reCaptchaSecret = WebConfig.RecaptchaPrivateKey; - - HttpClient httpClient = new HttpClient(); - var httpResponse = httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={reCaptchaSecret}&response={reCaptchResponse}").Result; - if (httpResponse.StatusCode != HttpStatusCode.OK) + try { - return errorResult.Value; - } + // POST keeps the secret out of URLs (request logs, proxies). + using var content = new FormUrlEncodedContent(new Dictionary + { + ["secret"] = reCaptchaSecret, + ["response"] = reCaptchResponse + }); - String jsonResponse = httpResponse.Content.ReadAsStringAsync().Result; - dynamic jsonData = JObject.Parse(jsonResponse); - if (jsonData.success != true.ToString().ToLower()) - { - return errorResult.Value; - } + var httpResponse = _httpClient.PostAsync("https://www.google.com/recaptcha/api/siteverify", content).GetAwaiter().GetResult(); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + return errorResult.Value; + } - return ValidationResult.Success; + String jsonResponse = httpResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + dynamic jsonData = JObject.Parse(jsonResponse); + if (jsonData.success != true.ToString().ToLower()) + { + return errorResult.Value; + } + return ValidationResult.Success; + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is OperationCanceledException) + { + // Transient network/DNS failure reaching Google: fail closed with a retryable + // validation message instead of letting the exception 500 the register page. + Framework.Logging.LogException(ex); + return new ValidationResult("We couldn't verify the reCAPTCHA right now. Please try again.", new String[] { validationContext.MemberName }); + } } } } diff --git a/Web/Resgrid.Web/Controllers/AccountController.cs b/Web/Resgrid.Web/Controllers/AccountController.cs index f5d33f6e..0199932c 100644 --- a/Web/Resgrid.Web/Controllers/AccountController.cs +++ b/Web/Resgrid.Web/Controllers/AccountController.cs @@ -782,17 +782,23 @@ public IActionResult AccessDenied() [HttpPost] public IActionResult SetLanugage(string culture, string returnUrl) { - if (!String.IsNullOrWhiteSpace(culture)) + // Whitelist the shipped locales: this anonymous endpoint gets scanner garbage ("'", + // paths, SQL fragments) and RequestCulture/CultureInfo throw on invalid names. + // Anything not supported is silently ignored instead of turning into a 500. + var supported = String.IsNullOrWhiteSpace(culture) + ? null + : Resgrid.Localization.SupportedLocales.GetSupportedCultures() + .FirstOrDefault(c => String.Equals(c, culture.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (supported != null) { - Response.Cookies.Append(CookieRequestCultureProvider.DefaultCookieName, CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)), new CookieOptions { Expires = DateTime.UtcNow.AddYears(1) }); + Response.Cookies.Append(CookieRequestCultureProvider.DefaultCookieName, CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(supported)), new CookieOptions { Expires = DateTime.UtcNow.AddYears(1) }); // This guy I think is causing issues with like DateTime rendering mm/dd/yy vs dd/mm/yy, so need to look into that more. -SJ //Thread.CurrentThread.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo(culture); - Thread.CurrentThread.CurrentUICulture = System.Globalization.CultureInfo.GetCultureInfo(culture); + Thread.CurrentThread.CurrentUICulture = System.Globalization.CultureInfo.GetCultureInfo(supported); if (!String.IsNullOrWhiteSpace(returnUrl)) return RedirectToLocal(returnUrl); - else - return RedirectToAction("LogOn"); } return RedirectToAction("LogOn");