logo
Published on

Getting User Profile Data with Graph in SPFx

The SharePoint user profile service has its own REST API, but Microsoft Graph gives you a richer, more consistent dataset — and it is the direction Microsoft is investing in going forward.
This article builds a complete user profile card web part using Graph: display name, job title, department, manager, contact details, profile photo, and presence status — all rendered with the Fluent UI v9 Persona component.


📦 Install Required Packages

npm install @fluentui/react-components --save
npm install @fluentui/react-icons --save

🗂️ Define the Profile Interface

src/models/IUserProfile.ts

export interface IUserProfile {
  id: string;
  displayName: string;
  mail: string;
  jobTitle: string | null;
  department: string | null;
  officeLocation: string | null;
  mobilePhone: string | null;
  businessPhones: string[];
  managerDisplayName: string | null;
  managerMail: string | null;
  photoUrl: string | null;
  presence: 'Available' | 'Busy' | 'Away' | 'DoNotDisturb' | 'BeRightBack' | 'Offline' | null;
}

🔧 Build the Profile Service

src/services/UserProfileService.ts

import { MSGraphClientV3 } from '@microsoft/sp-http';
import { IUserProfile } from '../models/IUserProfile';

export class UserProfileService {
  private readonly _client: MSGraphClientV3;

  constructor(client: MSGraphClientV3) {
    this._client = client;
  }

  public async getCurrentUserProfile(): Promise<IUserProfile> {
    // Fetch profile, manager, and presence in parallel
    const [profileResponse, managerResponse, presenceResponse, photoResponse] =
      await Promise.allSettled([
        this._client
          .api('/me')
          .select('id,displayName,mail,jobTitle,department,officeLocation,mobilePhone,businessPhones')
          .get(),
        this._client
          .api('/me/manager')
          .select('displayName,mail')
          .get(),
        this._client
          .api('/me/presence')
          .get(),
        this._client
          .api('/me/photo/$value')
          .responseType('blob' as any)
          .get()
      ]);

    // Base profile — required, throw if this fails
    if (profileResponse.status === 'rejected') {
      throw new Error(`Failed to load user profile: ${profileResponse.reason}`);
    }
    const profile = profileResponse.value;

    // Manager — optional, degrade gracefully on 404
    const manager = managerResponse.status === 'fulfilled'
      ? managerResponse.value
      : null;

    // Presence — optional, may be restricted by tenant policy
    const presence = presenceResponse.status === 'fulfilled'
      ? presenceResponse.value
      : null;

    // Profile photo — convert blob to object URL
    let photoUrl: string | null = null;
    if (photoResponse.status === 'fulfilled' && photoResponse.value) {
      try {
        photoUrl = URL.createObjectURL(photoResponse.value);
      } catch {
        photoUrl = null;
      }
    }

    return {
      id: profile.id,
      displayName: profile.displayName,
      mail: profile.mail,
      jobTitle: profile.jobTitle ?? null,
      department: profile.department ?? null,
      officeLocation: profile.officeLocation ?? null,
      mobilePhone: profile.mobilePhone ?? null,
      businessPhones: profile.businessPhones ?? [],
      managerDisplayName: manager?.displayName ?? null,
      managerMail: manager?.mail ?? null,
      photoUrl,
      presence: presence?.availability ?? null
    };
  }
}

Using Promise.allSettled ensures the profile loads even if the manager lookup or presence check fails due to permissions or policy. Each optional piece degrades independently.


⚛️ Build the Profile Card Component

src/components/UserProfileCard.tsx

import * as React from 'react';
import {
  Avatar,
  Badge,
  Text,
  makeStyles,
  tokens
} from '@fluentui/react-components';
import {
  MailRegular,
  PhoneRegular,
  BuildingRegular,
  PeopleRegular,
  LocationRegular
} from '@fluentui/react-icons';
import { IUserProfile } from '../models/IUserProfile';

const useStyles = makeStyles({
  card: {
    display: 'flex',
    flexDirection: 'column',
    gap: tokens.spacingVerticalM,
    padding: tokens.spacingVerticalL,
    background: tokens.colorNeutralBackground1,
    borderRadius: tokens.borderRadiusMedium,
    boxShadow: tokens.shadow4,
    maxWidth: '360px'
  },
  header: {
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalM
  },
  avatarWrap: {
    position: 'relative'
  },
  presenceBadge: {
    position: 'absolute',
    bottom: 0,
    right: 0
  },
  details: {
    flex: 1
  },
  name: {
    fontWeight: tokens.fontWeightSemibold,
    fontSize: tokens.fontSizeBase400
  },
  title: {
    color: tokens.colorNeutralForeground2,
    fontSize: tokens.fontSizeBase200
  },
  infoRow: {
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalS,
    fontSize: tokens.fontSizeBase200,
    color: tokens.colorNeutralForeground2
  },
  divider: {
    height: '1px',
    background: tokens.colorNeutralStroke1
  }
});

const PRESENCE_COLORS: Record<string, 'available' | 'away' | 'busy' | 'do-not-disturb' | 'offline' | 'out-of-office'> = {
  Available: 'available',
  Away: 'away',
  BeRightBack: 'away',
  Busy: 'busy',
  DoNotDisturb: 'do-not-disturb',
  Offline: 'offline'
};

interface IUserProfileCardProps {
  profile: IUserProfile;
}

const UserProfileCard: React.FC<IUserProfileCardProps> = ({ profile }) => {
  const styles = useStyles();
  const presenceStatus = profile.presence ? PRESENCE_COLORS[profile.presence] : undefined;

  return (
    <div className={styles.card}>
      <div className={styles.header}>
        <div className={styles.avatarWrap}>
          <Avatar
            name={profile.displayName}
            image={profile.photoUrl ? { src: profile.photoUrl } : undefined}
            size={56}
          />
          {presenceStatus && (
            <Badge
              className={styles.presenceBadge}
              size="small"
              status={presenceStatus}
            />
          )}
        </div>
        <div className={styles.details}>
          <Text className={styles.name} block>{profile.displayName}</Text>
          {profile.jobTitle && (
            <Text className={styles.title} block>{profile.jobTitle}</Text>
          )}
          {profile.department && (
            <Text className={styles.title} block>{profile.department}</Text>
          )}
        </div>
      </div>

      <div className={styles.divider} />

      {profile.mail && (
        <div className={styles.infoRow}>
          <MailRegular />
          <a href={`mailto:${profile.mail}`}>{profile.mail}</a>
        </div>
      )}
      {profile.businessPhones[0] && (
        <div className={styles.infoRow}>
          <PhoneRegular />
          <span>{profile.businessPhones[0]}</span>
        </div>
      )}
      {profile.officeLocation && (
        <div className={styles.infoRow}>
          <LocationRegular />
          <span>{profile.officeLocation}</span>
        </div>
      )}
      {profile.managerDisplayName && (
        <div className={styles.infoRow}>
          <PeopleRegular />
          <span>Reports to: {profile.managerDisplayName}</span>
        </div>
      )}
    </div>
  );
};

export default UserProfileCard;

🔧 Wire It Up in the Web Part

import { MSGraphClientV3 } from '@microsoft/sp-http';
import { UserProfileService } from './services/UserProfileService';
import { IUserProfile } from './models/IUserProfile';

export default class UserProfileWebPart extends BaseClientSideWebPart<{}> {
  private _profileService: UserProfileService;

  protected async onInit(): Promise<void> {
    await super.onInit();
    const client: MSGraphClientV3 =
      await this.context.msGraphClientFactory.getClient('3');
    this._profileService = new UserProfileService(client);
  }

  public render(): void {
    const element = React.createElement(UserProfileContainer, {
      profileService: this._profileService
    });
    ReactDOM.render(
      React.createElement(FluentProvider, { theme: webLightTheme }, element),
      this.domElement
    );
  }

  protected onDispose(): void {
    ReactDOM.unmountComponentAtNode(this.domElement);
  }
}

const UserProfileContainer: React.FC<{ profileService: UserProfileService }> = ({ profileService }) => {
  const [profile, setProfile] = React.useState<IUserProfile | null>(null);
  const [error, setError] = React.useState<string | null>(null);

  React.useEffect(() => {
    profileService.getCurrentUserProfile()
      .then(setProfile)
      .catch(err => setError(err.message));
  }, []);

  if (error) return <p>Error: {error}</p>;
  if (!profile) return <Spinner label="Loading profile..." />;
  return <UserProfileCard profile={profile} />;
};

📋 Required Permissions

{
  "solution": {
    "webApiPermissionRequests": [
      { "resource": "Microsoft Graph", "scope": "User.Read" },
      { "resource": "Microsoft Graph", "scope": "User.ReadBasic.All" },
      { "resource": "Microsoft Graph", "scope": "Presence.Read.All" }
    ]
  }
}

Presence.Read.All is optional — the card degrades gracefully without it. Only request it if your tenant allows it and your use case genuinely needs presence status.


🚀 Deploy

npm run build
npm run bundle -- --ship
npm run package-solution -- --ship

📂 GitHub Source

View full SPFx project on GitHub:SPFx user profile card web part — Graph API + Fluent UI v9 Persona component

GitHub

✅ Summary

  • Use Promise.allSettled to fetch profile, manager, presence, and photo in parallel — each piece degrades independently if permissions are missing.
  • Convert the photo blob to an objectURL for use in the Avatar component — revoke it in onDispose to avoid memory leaks.
  • Map Graph presence availability strings to Fluent UI v9 Badge status values for the presence indicator.
  • Declare Presence.Read.All only if you need presence — it is sensitive and often restricted in enterprise tenants.
  • The UserProfileService pattern keeps all Graph calls out of your React components.

Happy coding!

Ad image