8 Commits

Author SHA1 Message Date
f4737f4dcf Don't create self signed cert in production build pipeline
All checks were successful
Build Backend and Frontend / Build & Test .NET Backend (push) Successful in 32s
Build Backend and Frontend / Build Frontend (push) Successful in 12s
2025-05-23 16:33:23 +02:00
1d68f177de Fixed a small stupid mistake
Some checks failed
Build Backend and Frontend / Build & Test .NET Backend (push) Successful in 31s
Build Backend and Frontend / Build Frontend (push) Failing after 11s
2025-05-23 16:18:14 +02:00
ae5096bacd Installed zod, because I somehow didin't have to do that locally
Some checks failed
Build Backend and Frontend / Build & Test .NET Backend (push) Successful in 41s
Build Backend and Frontend / Build Frontend (push) Failing after 10s
- Also use v4
2025-05-23 16:05:08 +02:00
a2608eac5b Rebuild package-lock.json
Some checks failed
Build Backend and Frontend / Build & Test .NET Backend (push) Successful in 36s
Build Backend and Frontend / Build Frontend (push) Failing after 10s
2025-05-23 15:58:26 +02:00
189069361f Merge branch 'feature/login_and_auth' into feature/ci-cd-pipeline
Some checks failed
Build Backend and Frontend / Build & Test .NET Backend (push) Successful in 33s
Build Backend and Frontend / Build Frontend (push) Failing after 12s
2025-05-23 15:41:13 +02:00
17817ea0a9 Added launch config to run everything 2025-05-23 15:38:45 +02:00
a0c232e052 More styling for linter, more better code 2025-05-23 13:56:34 +02:00
3e7b04df1c Basic login an authorize in backend 2025-05-22 18:26:09 +02:00
17 changed files with 734 additions and 1560 deletions

View File

@ -22,5 +22,5 @@ jobs:
working-directory: ./usentrycoach.client
- name: Build frontend
run: npm run build
run: NODE_ENV=production npm run build
working-directory: ./usentrycoach.client

View File

@ -0,0 +1,46 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Backend and Frontend" type="com.intellij.execution.configurations.multilaunch" factoryName="MultiLaunchConfiguration">
<rows>
<ExecutableRowSnapshot>
<option name="condition">
<ConditionSnapshot>
<option name="type" value="immediately" />
</ConditionSnapshot>
</option>
<option name="executable">
<ExecutableSnapshot>
<option name="id" value="buildSolution:0942b9" />
</ExecutableSnapshot>
</option>
</ExecutableRowSnapshot>
<ExecutableRowSnapshot>
<option name="condition">
<ConditionSnapshot>
<option name="type" value="afterPreviousFinished" />
</ConditionSnapshot>
</option>
<option name="executable">
<ExecutableSnapshot>
<option name="id" value="runConfig:.NET Launch Settings Profile.USEntryCoach.Server: https" />
</ExecutableSnapshot>
</option>
</ExecutableRowSnapshot>
<ExecutableRowSnapshot>
<option name="condition">
<ConditionSnapshot>
<attributes>
<entry key="port" value="7085" />
</attributes>
<option name="type" value="waitPortOpened" />
</ConditionSnapshot>
</option>
<option name="executable">
<ExecutableSnapshot>
<option name="id" value="runConfig:npm.dev" />
</ExecutableSnapshot>
</option>
</ExecutableRowSnapshot>
</rows>
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,7 @@
namespace USEntryCoach.Server.Data;
class LoginCredentials
{
public string Username { get; set; }
public string Password { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace USEntryCoach.Server.Data;
public class User
{
public Guid Id { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public UserRole Role { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace USEntryCoach.Server.Data;
public enum UserRole
{
Developer,
User
}

View File

@ -1,13 +1,68 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.IdentityModel.Tokens;
using USEntryCoach.Server.Data;
using USEntryCoach.Server.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddSingleton<TokenService>();
// Configure JWT token generation.
builder.Services.AddAuthentication(config =>
{
config.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
config.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(config =>
{
// TODO: Use Microsoft.Extensions.Options?
// var myOptions = new MyOptions(); // Your settings class
// builder.Configuration.GetSection("MySection").Bind(myOptions);
// myOptions now has the values
//
// TODO: This is identical in TokenService
string? secretToken = builder.Configuration.GetValue<string>("Authentication:Secret");
if (secretToken == null)
{
throw new Exception("No Authentication Secret Token set! Please define a value for \"Authentication:SecretToken\" in appsettings.json.");
}
byte[] secretKey = Encoding.ASCII.GetBytes(secretToken);
// TODO: Only for debug!
config.RequireHttpsMetadata = false;
config.SaveToken = true;
config.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(secretKey),
ValidateIssuer = false,
ValidateAudience = false
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(nameof(UserRole.Developer), policy => policy.RequireRole(nameof(UserRole.Developer)));
options.AddPolicy(nameof(UserRole.User), policy =>
{
// Also allow Developers to do anything a user can do.
policy.RequireRole(nameof(UserRole.User), nameof(UserRole.Developer));
});
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseDefaultFiles();
app.MapStaticAssets();
@ -24,13 +79,61 @@ string? apiKey = app.Configuration.GetValue<string>("API:OpenAI");
client.DefaultRequestHeaders.Clear();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
app.MapPost("/login", Login);
app.MapGet("/hello", () => "Hello World!").RequireAuthorization("admin_greetings");
async Task<IResult> Login(LoginCredentials credentials, TokenService tokenService, HttpContext context)
{
// TODO: Check with database
User? user = null;
if (credentials is {Username:"developer", Password:"dev"})
{
user = new User()
{
Username = credentials.Username,
Password = credentials.Password,
Id = Guid.CreateVersion7(),
Role = UserRole.Developer
};
}
else if (credentials is {Username:"user", Password:"us"})
{
user = new User()
{
Username = credentials.Username,
Password = credentials.Password,
Id = Guid.CreateVersion7(),
Role = UserRole.User
};
}
if (user == null)
{
return Results.Unauthorized();
}
var token = tokenService.GenerateToken(user);
return Results.Ok(new { token });
}
app.MapGet("/developer", (ClaimsPrincipal user) =>
{
Results.Ok(new { message = $"Authenticated as { user?.Identity?.Name }" });
}).RequireAuthorization(nameof(UserRole.Developer));
app.MapGet("/user", (ClaimsPrincipal user) =>
{
Results.Ok(new { message = $"Authenticated as { user?.Identity?.Name }" });
}).RequireAuthorization(nameof(UserRole.User));
app.MapGet("/ephemeral_token", async () =>
{
if (apiKey == null)
throw new Exception("API key not set");
#pragma warning disable OPENAI002
var options = new
{
model = "gpt-4o-mini-realtime-preview",
@ -44,8 +147,6 @@ app.MapGet("/ephemeral_token", async () =>
//}//ConversationTurnDetectionOptions.CreateServerVoiceActivityTurnDetectionOptions(0.5f, TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(500), true)
};
#pragma warning restore OPENAI002
try
{
JsonContent content = JsonContent.Create(options);
@ -76,7 +177,7 @@ app.MapGet("/ephemeral_token", async () =>
}
return null;
}).WithName("GetEphemeralToken");
}).WithName("GetEphemeralToken").RequireAuthorization(nameof(UserRole.User));
app.MapFallbackToFile("/index.html");

View File

@ -0,0 +1,56 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using USEntryCoach.Server.Data;
namespace USEntryCoach.Server.Services;
public class TokenService
{
private byte[] _secretToken;
private double _jwtExpiryMinutes;
private const double DefaultJwtExpiryMinutes = 15;
public TokenService(IConfiguration configuration)
{
string? secretToken = configuration.GetValue<string>("Authentication:Secret");
if (secretToken == null)
{
throw new Exception("No Authentication Secret Token set! Please define a value for \"Authentication:SecretToken\" in appsettings.json.");
}
_secretToken = Encoding.ASCII.GetBytes(secretToken);
double? jwtExpiryMinutes = configuration.GetValue<double?>("Authentication:JwtExpiryMinutes");
if (jwtExpiryMinutes == null)
{
// TODO: Use logger
Console.WriteLine($"Warning: No expiry time for jwt session tokens defined. Using {DefaultJwtExpiryMinutes} minutes.");
}
_jwtExpiryMinutes = jwtExpiryMinutes ?? DefaultJwtExpiryMinutes;
}
public string GenerateToken(User user)
{
JwtSecurityTokenHandler tokenHandler = new();
SecurityTokenDescriptor tokenDescriptor = new()
{
Subject = new ClaimsIdentity([
new Claim(ClaimTypes.Name, user.Username),
//new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Role, user.Role.ToString())
]),
Expires = DateTime.UtcNow.AddMinutes(_jwtExpiryMinutes),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(_secretToken), SecurityAlgorithms.HmacSha256Signature)
};
SecurityToken token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}

View File

@ -13,6 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SpaProxy">
<Version>9.*-*</Version>

View File

@ -4,5 +4,11 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"API": {
"OpenAI": "Please set the key in secrets.json! NEVER HERE!!!"
},
"Authentication": {
"Secret": "Please provide a GUID (without dashes) as secret."
}
}

View File

@ -3,26 +3,56 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import stylistic from '@stylistic/eslint-plugin'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
{ignores: ['dist']},
{
extends: [
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
//'stylistic/js': stylisticJs
stylistic
},
rules: {
...reactHooks.configs.recommended.rules,
//'brace-style': ['error', 'allman'],
'react-refresh/only-export-components': [
'warn',
{allowConstantExport: true},
],
"@typescript-eslint/no-unused-vars": ["warn", {
"vars": "all",
"args": "all",
"caughtErrors": "all",
"ignoreRestSiblings": false,
"reportUsedIgnorePattern": false
}],
"prefer-const": ["warn", {
"destructuring": "any",
"ignoreReadBeforeAssign": false
}],
// Formatting:
"stylistic/brace-style": [
'error',
'allman',
{ allowSingleLine: false }
]
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,13 @@
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"zod": "^3.25.23"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@stylistic/eslint-plugin": "^4.2.0",
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
@ -24,7 +27,6 @@
"globals": "^16.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"@types/node": "^20"
"vite": "^6.3.5"
}
}
}

View File

@ -1,415 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import './App.css';
import {ChatControl} from "./ChatClient/ChatControl.tsx";
import Login from './Components/Login.tsx';
import useLoginToken from "./Hooks/useLoginToken.tsx";
interface Forecast {
date: string;
temperatureC: number;
temperatureF: number;
summary: string;
}
export default function App()
{
const {token, setToken} = useLoginToken();
interface OpenAiToken {
ephemeralToken: string;
expiresAt: number;
}
export default function App() {
if (!token)
{
return <Login setToken={setToken} />
}
return <ChatControl/>
// const [isSessionActive, setIsSessionActive] = useState(false);
// const [events, setEvents] = useState([]);
// const [dataChannel, setDataChannel] = useState<RTCDataChannel | null>(null);
// const peerConnection = useRef<RTCPeerConnection>(null);
// const audioElement = useRef<HTMLAudioElement>(null);
//
// async function startSession() {
// // Get a session token for OpenAI Realtime API
// const tokenResponse = await fetch("/ephemeral_token");
// const data: OpenAiToken = await tokenResponse.json();
// const EPHEMERAL_KEY = data.ephemeralToken;
//
// // Create a peer connection
// const pc = new RTCPeerConnection();
//
// // Set up to play remote audio from the model
// audioElement.current = document.createElement("audio");
// audioElement.current.autoplay = true;
// pc.ontrack = (e) => {
// if (audioElement?.current !== null) {
// audioElement.current.srcObject = e.streams[0]
// }
// };
//
// // Add local audio track for microphone input in the browser
// const ms = await navigator.mediaDevices.getUserMedia({
// audio: true,
// });
// pc.addTrack(ms.getTracks()[0]);
//
// // Set up data channel for sending and receiving events
// const dc = pc.createDataChannel("oai-events");
// setDataChannel(dc);
//
// // Start the session using the Session Description Protocol (SDP)
// const offer = await pc.createOffer();
// await pc.setLocalDescription(offer);
//
// const baseUrl = "https://api.openai.com/v1/realtime";
// const model = "gpt-4o-realtime-preview-2024-12-17";
// const sdpResponse = await fetch(`${baseUrl}?model=${model}`, {
// method: "POST",
// body: offer.sdp,
// headers: {
// Authorization: `Bearer ${EPHEMERAL_KEY}`,
// "Content-Type": "application/sdp",
// },
// });
//
// const answer: RTCSessionDescriptionInit = {
// type: "answer",
// sdp: await sdpResponse.text(),
// };
// await pc.setRemoteDescription(answer);
//
// peerConnection.current = pc;
// }
//
// // Stop current session, clean up peer connection and data channel
// function stopSession() {
// if (dataChannel) {
// dataChannel.close();
// }
//
// if (peerConnection?.current !== null)
// {
// peerConnection.current.getSenders().forEach((sender) => {
// if (sender.track) {
// sender.track.stop();
// }
// });
//
// if (peerConnection.current) {
// peerConnection.current.close();
// }
// }
//
// setIsSessionActive(false);
// setDataChannel(null);
// peerConnection.current = null;
// }
//
// // Send a message to the model
// function sendClientEvent(message) {
// if (dataChannel) {
// const timestamp = new Date().toLocaleTimeString();
// message.event_id = message.event_id || crypto.randomUUID();
//
// // send event before setting timestamp since the backend peer doesn't expect this field
// dataChannel.send(JSON.stringify(message));
//
// // if guard just in case the timestamp exists by miracle
// if (!message.timestamp) {
// message.timestamp = timestamp;
// }
// setEvents((prev) => [message, ...prev]);
// } else {
// console.error(
// "Failed to send message - no data channel available",
// message,
// );
// }
// }
//
// // Send a text message to the model
// function sendTextMessage(message) {
// const event = {
// type: "conversation.item.create",
// item: {
// type: "message",
// role: "user",
// content: [
// {
// type: "input_text",
// text: message,
// },
// ],
// },
// };
//
// sendClientEvent(event);
// sendClientEvent({ type: "response.create" });
// }
//
// // Attach event listeners to the data channel when a new one is created
// useEffect(() => {
// if (dataChannel) {
// // Append new server events to the list
// dataChannel.addEventListener("message", (e) => {
// const event = JSON.parse(e.data);
// if (!event.timestamp) {
// event.timestamp = new Date().toLocaleTimeString();
// }
//
// setEvents((prev) => [event, ...prev]);
// });
//
// // Set session active when the data channel is opened
// dataChannel.addEventListener("open", () => {
// setIsSessionActive(true);
// setEvents([]);
// });
// }
// }, [dataChannel]);
//
// return (
// <>
// <nav className="absolute top-0 left-0 right-0 h-16 flex items-center">
// <div className="flex items-center gap-4 w-full m-4 pb-2 border-0 border-b border-solid border-gray-200">
// <img style={{ width: "24px" }} src={logo} />
// <h1>realtime console</h1>
// </div>
// </nav>
// <main className="absolute top-16 left-0 right-0 bottom-0">
// <section className="absolute top-0 left-0 right-[380px] bottom-0 flex">
// <section className="absolute top-0 left-0 right-0 bottom-32 px-4 overflow-y-auto">
// <EventLog events={events} />
// </section>
// <section className="absolute h-32 left-0 right-0 bottom-0 p-4">
// <SessionControls
// startSession={startSession}
// stopSession={stopSession}
// sendClientEvent={sendClientEvent}
// sendTextMessage={sendTextMessage}
// events={events}
// isSessionActive={isSessionActive}
// />
// </section>
// </section>
// <section className="absolute top-0 w-[380px] right-0 bottom-0 p-4 pt-0 overflow-y-auto">
// <ToolPanel
// sendClientEvent={sendClientEvent}
// sendTextMessage={sendTextMessage}
// events={events}
// isSessionActive={isSessionActive}
// />
// </section>
// </main>
// </>
// );
}
// export default function App() {
// const [openAiToken, setOpenAiToken] = useState<OpenAiToken | null>();
//
// useEffect(() => {
// get_the_fucking_token_already();
// }, []);
//
// const contents = openAiToken === undefined
// ? <p>Connecting to Open AI...</p>
// : <div>
// {
// openAiToken === null
// ? <p>Failed to connect to OpenAI, please reload the page.</p>
// : <p>
// {openAiToken.ephemeralToken}<br/>
// {new Date(openAiToken.expiresAt * 1000).toLocaleString()}
// </p>
// }
// </div>;
// return (
// <div>
// <h1 id="tableLabel">Weather forecast</h1>
// <p>This component demonstrates fetching data from the server.</p>
// {contents}
// </div>
// );
//
// async function get_the_fucking_token_already() {
// const response = await fetch('ephemeral_token');
// if (response.ok) {
// const data = await response.json();
// setOpenAiToken(data);
// }
// }
// }
//
// function Chatter({ openAiToken }: { openAiToken: OpenAiToken }) {
// const [isSessionActive, setIsSessionActive] = useState(false);
// const [events, setEvents] = useState([]);
// const [dataChannel, setDataChannel] = useState(null);
// const peerConnection = useRef(null);
// const audioElement = useRef(null);
//
// async function startSession() {
// // Get a session token for OpenAI Realtime API
// const tokenResponse = await fetch("/token");
// const data = await tokenResponse.json();
// const EPHEMERAL_KEY = data.client_secret.value;
//
// // Create a peer connection
// const pc = new RTCPeerConnection();
//
// // Set up to play remote audio from the model
// audioElement.current = document.createElement("audio");
// audioElement.current.autoplay = true;
// pc.ontrack = (e) => (audioElement.current.srcObject = e.streams[0]);
//
// // Add local audio track for microphone input in the browser
// const ms = await navigator.mediaDevices.getUserMedia({
// audio: true,
// });
// pc.addTrack(ms.getTracks()[0]);
//
// // Set up data channel for sending and receiving events
// const dc = pc.createDataChannel("oai-events");
// setDataChannel(dc);
//
// // Start the session using the Session Description Protocol (SDP)
// const offer = await pc.createOffer();
// await pc.setLocalDescription(offer);
//
// const baseUrl = "https://api.openai.com/v1/realtime";
// const model = "gpt-4o-realtime-preview-2024-12-17";
// const sdpResponse = await fetch(`${baseUrl}?model=${model}`, {
// method: "POST",
// body: offer.sdp,
// headers: {
// Authorization: `Bearer ${EPHEMERAL_KEY}`,
// "Content-Type": "application/sdp",
// },
// });
//
// const answer = {
// type: "answer",
// sdp: await sdpResponse.text(),
// };
// await pc.setRemoteDescription(answer);
//
// peerConnection.current = pc;
// }
//
// // Stop current session, clean up peer connection and data channel
// function stopSession() {
// if (dataChannel) {
// dataChannel.close();
// }
//
// peerConnection.current.getSenders().forEach((sender) => {
// if (sender.track) {
// sender.track.stop();
// }
// });
//
// if (peerConnection.current) {
// peerConnection.current.close();
// }
//
// setIsSessionActive(false);
// setDataChannel(null);
// peerConnection.current = null;
// }
//
// // Send a message to the model
// function sendClientEvent(message) {
// if (dataChannel) {
// const timestamp = new Date().toLocaleTimeString();
// message.event_id = message.event_id || crypto.randomUUID();
//
// // send event before setting timestamp since the backend peer doesn't expect this field
// dataChannel.send(JSON.stringify(message));
//
// // if guard just in case the timestamp exists by miracle
// if (!message.timestamp) {
// message.timestamp = timestamp;
// }
// setEvents((prev) => [message, ...prev]);
// } else {
// console.error(
// "Failed to send message - no data channel available",
// message,
// );
// }
// }
//
// // Send a text message to the model
// function sendTextMessage(message) {
// const event = {
// type: "conversation.item.create",
// item: {
// type: "message",
// role: "user",
// content: [
// {
// type: "input_text",
// text: message,
// },
// ],
// },
// };
//
// sendClientEvent(event);
// sendClientEvent({ type: "response.create" });
// }
//
// // Attach event listeners to the data channel when a new one is created
// useEffect(() => {
// if (dataChannel) {
// // Append new server events to the list
// dataChannel.addEventListener("message", (e) => {
// const event = JSON.parse(e.data);
// if (!event.timestamp) {
// event.timestamp = new Date().toLocaleTimeString();
// }
//
// setEvents((prev) => [event, ...prev]);
// });
//
// // Set session active when the data channel is opened
// dataChannel.addEventListener("open", () => {
// setIsSessionActive(true);
// setEvents([]);
// });
// }
// }, [dataChannel]);
//
// return (
// <>
// <nav className="absolute top-0 left-0 right-0 h-16 flex items-center">
// <div className="flex items-center gap-4 w-full m-4 pb-2 border-0 border-b border-solid border-gray-200">
// <img style={{ width: "24px" }} src={logo} />
// <h1>realtime console</h1>
// </div>
// </nav>
// <main className="absolute top-16 left-0 right-0 bottom-0">
// <section className="absolute top-0 left-0 right-[380px] bottom-0 flex">
// <section className="absolute top-0 left-0 right-0 bottom-32 px-4 overflow-y-auto">
// <EventLog events={events} />
// </section>
// <section className="absolute h-32 left-0 right-0 bottom-0 p-4">
// <SessionControls
// startSession={startSession}
// stopSession={stopSession}
// sendClientEvent={sendClientEvent}
// sendTextMessage={sendTextMessage}
// events={events}
// isSessionActive={isSessionActive}
// />
// </section>
// </section>
// <section className="absolute top-0 w-[380px] right-0 bottom-0 p-4 pt-0 overflow-y-auto">
// <ToolPanel
// sendClientEvent={sendClientEvent}
// sendTextMessage={sendTextMessage}
// events={events}
// isSessionActive={isSessionActive}
// />
// </section>
// </main>
// </>
// );
// }

View File

@ -1,10 +1,14 @@
import {useState, useRef, useEffect} from 'react';
import useLoginToken from "../Hooks/useLoginToken.tsx";
import { z } from 'zod/v4';
function SessionActive({stopSession} : {stopSession: () => void}) {
function SessionActive({stopSession} : {stopSession: () => void})
{
return <button onClick={stopSession}>Stop session</button>
}
function SessionStopped({startSession} : {startSession: () => void}) {
function SessionStopped({startSession} : {startSession: () => void})
{
return <button onClick={startSession}>Start session</button>
}
@ -15,26 +19,37 @@ function SessionControl( { isSessionActive, startSession, stopSession }: { isSes
: <SessionStopped startSession={startSession}/>);
}
interface OpenAiToken {
ephemeralToken: string;
expiresAt: number;
}
const EphemeralTokenResponse = z.object({
ephemeralToken: z.string(),
expiresAt: z.number(),
});
export function ChatControl()
{
const { token } = useLoginToken();
export function ChatControl() {
const [isSessionActive, setSessionActive] = useState<boolean>(false);
const [dataChannel, setDataChannel] = useState<RTCDataChannel | null>(null);
const audioElement = useRef<HTMLAudioElement>(null);
const peerConnection = useRef<RTCPeerConnection>(null);
async function StartSession() {
async function StartSession()
{
// Get a session token for OpenAI Realtime API
const response = await fetch('ephemeral_token');
if (!response.ok) {
const response = await fetch('ephemeral_token', {
method: "GET",
headers: {
Authorization: `Bearer ${token}asdasd`
},
});
if (!response.ok)
{
throw new Error(response.statusText);
}
const data: OpenAiToken = await response.json();
const ephemeralToken = data.ephemeralToken;
const responseJson:unknown = await response.json();
const parsedToken = EphemeralTokenResponse.parse(responseJson);
const ephemeralToken = parsedToken.ephemeralToken;
// Create a peer connection
const pc = new RTCPeerConnection();
@ -42,7 +57,8 @@ export function ChatControl() {
// Set up to play remote audio from the model
audioElement.current = document.createElement("audio");
audioElement.current.autoplay = true;
pc.ontrack = (e) => {
pc.ontrack = (e) =>
{
if (audioElement.current !== null)
{
audioElement.current.srcObject = e.streams[0];
@ -82,8 +98,10 @@ export function ChatControl() {
peerConnection.current = pc;
}
function stopSession() {
if (dataChannel) {
function stopSession()
{
if (dataChannel)
{
dataChannel.close();
setDataChannel(null);
}
@ -97,13 +115,16 @@ export function ChatControl() {
setSessionActive(false);
}
useEffect(() => {
if (dataChannel) {
dataChannel.addEventListener("open", () => {
useEffect(() =>
{
if (dataChannel)
{
dataChannel.addEventListener("open", () =>
{
setSessionActive(true);
});
}
}, [dataChannel])
return <SessionControl isSessionActive={isSessionActive} startSession={StartSession} stopSession={stopSession}/>;
return <SessionControl isSessionActive={isSessionActive} startSession={() => void StartSession()} stopSession={stopSession}/>;
}

View File

@ -0,0 +1,60 @@
import { useState, type FormEvent } from 'react';
import { z } from 'zod/v4';
export default function Login({ setToken } : {setToken: (token: string) => void})
{
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
async function doLogin(event: FormEvent<HTMLFormElement>)
{
event.preventDefault();
const token = await loginUser({username, password});
setToken(token);
}
return (
<div>
<form className="login-form" onSubmit={e => void doLogin(e)} method="action">
<label>
Email:
<input type="text" name="username" placeholder="Username" required onChange={event => setUsername(event.target.value)} />
</label>
<label>
Password:
<input type="password" name="password" placeholder="Password" required onChange={event => setPassword(event.target.value)} />
</label>
<button type="submit">Login</button>
</form>
</div>
);
}
const LoginResponse = z.object({
token: z.string()
});
async function loginUser(credentials : {username: string, password: string}): Promise<string>
{
// Get a session token for OpenAI Realtime API
const response = await fetch('login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
if (!response.ok)
{
throw new Error(response.statusText);
}
const responseJson:unknown = await response.json();
const parsedToken = LoginResponse.parse(responseJson);
return parsedToken.token;
}

View File

@ -0,0 +1,27 @@
import {useState} from "react";
/**
* A React hook that allows to access the session token (which might be in session or local storage).
* It also allows to set the token.
*/
export default function useLoginToken()
{
const getToken = () =>
{
const tokenJson = sessionStorage.getItem('token');
return tokenJson ? JSON.parse(tokenJson) as string : null;
};
const [token, setToken] = useState<string | null>(getToken());
const saveToken = (token: string): void =>
{
sessionStorage.setItem('token', JSON.stringify(token));
setToken(token);
}
// In React it is common for hooks to return arrays. However, returning an object allows for partial destruction
// (that is, user can grab only the values they need).
return { token, setToken: saveToken };
}

View File

@ -1,12 +1,14 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import { defineConfig, type ProxyOptions } from 'vite';
import plugin from '@vitejs/plugin-react';
import fs from 'fs';
import path from 'path';
import child_process from 'child_process';
import { env } from 'process';
const isDevelopment = env.NODE_ENV !== 'production';
const baseFolder =
env.APPDATA !== undefined && env.APPDATA !== ''
? `${env.APPDATA}/ASP.NET/https`
@ -16,11 +18,14 @@ const certificateName = "usentrycoach.client";
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);
if (!fs.existsSync(baseFolder)) {
if (!fs.existsSync(baseFolder))
{
fs.mkdirSync(baseFolder, { recursive: true });
}
if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
// Generate dev certificate, if we are in development mode, and it doesn't exist yet.
if (isDevelopment && (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)))
{
if (0 !== child_process.spawnSync('dotnet', [
'dev-certs',
'https',
@ -29,7 +34,8 @@ if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
'--format',
'Pem',
'--no-password',
], { stdio: 'inherit', }).status) {
], { stdio: 'inherit', }).status)
{
throw new Error("Could not create certificate.");
}
}
@ -37,6 +43,23 @@ if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
const target = env.ASPNETCORE_HTTPS_PORT ? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}` :
env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'https://localhost:7085';
// Define a list of all existing backend routes.
const backendRoutes = ['/login', '/ephemeral_token'];
// For development, we have a node.js server running that delivers our frontend.
// We have to configure proxies for the backend calls, so that node.js will forward them to the backend.
const proxyConfig = backendRoutes.reduce((acc, path) =>
{
acc[path] = {
target: target,
// disable certificate verification because the development certificate is self-signed
secure: false,
changeOrigin: true
}
return acc;
}, {} as Record<string, ProxyOptions>);
// https://vitejs.dev/config/
export default defineConfig({
plugins: [plugin()],
@ -46,16 +69,12 @@ export default defineConfig({
}
},
server: {
proxy: {
'^/ephemeral_token': {
target,
secure: false
}
},
port: parseInt(env.DEV_SERVER_PORT || '54044'),
https: {
proxy: proxyConfig,
port: parseInt(env.DEV_SERVER_PORT ?? '54044'),
// This is only relevant for development anyway.
https: isDevelopment ? {
key: fs.readFileSync(keyFilePath),
cert: fs.readFileSync(certFilePath),
}
} : undefined
}
})